From f1b30a1a049291a470b9fd861b89ff7f866a5ec6 Mon Sep 17 00:00:00 2001 From: tridion Date: Wed, 3 Dec 2025 08:56:55 -0500 Subject: [PATCH 1/4] feat: Add IGNORE_EXTERNAL_DNS() for Kubernetes external-dns coexistence (#3869)s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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> --- commands/types/dnscontrol.d.ts | 183 ++++++ documentation/SUMMARY.md | 1 + .../domain-modifiers/IGNORE_EXTERNAL_DNS.md | 196 +++++++ models/domain.go | 3 + pkg/diff2/diff2.go | 2 + pkg/diff2/externaldns.go | 230 ++++++++ pkg/diff2/externaldns_test.go | 528 ++++++++++++++++++ pkg/diff2/handsoff.go | 75 +++ pkg/diff2/handsoff_test.go | 201 +++++++ pkg/js/helpers.js | 91 ++- pkg/js/parse_tests/058-ignore-external-dns.js | 13 + .../parse_tests/058-ignore-external-dns.json | 52 ++ 12 files changed, 1545 insertions(+), 30 deletions(-) create mode 100644 documentation/language-reference/domain-modifiers/IGNORE_EXTERNAL_DNS.md create mode 100644 pkg/diff2/externaldns.go create mode 100644 pkg/diff2/externaldns_test.go create mode 100644 pkg/js/parse_tests/058-ignore-external-dns.js create mode 100644 pkg/js/parse_tests/058-ignore-external-dns.json diff --git a/commands/types/dnscontrol.d.ts b/commands/types/dnscontrol.d.ts index 6e3047892..ae3ab5ac1 100644 --- a/commands/types/dnscontrol.d.ts +++ b/commands/types/dnscontrol.d.ts @@ -1671,6 +1671,189 @@ declare function HTTPS(name: string, priority: number, target: string, params: s */ declare function IGNORE(labelSpec: string, typeSpec?: string, targetSpec?: string): DomainModifier; +/** + * `IGNORE_EXTERNAL_DNS` makes DNSControl automatically detect and ignore DNS records + * managed by Kubernetes external-dns. + * + * ## Background + * + * [External-dns](https://github.com/kubernetes-sigs/external-dns) is a popular + * Kubernetes controller that synchronizes exposed Kubernetes Services and Ingresses + * with DNS providers. It creates DNS records automatically based on annotations on + * your Kubernetes resources. + * + * External-dns uses TXT records to track ownership of the DNS records it manages. + * These TXT records contain metadata in this format: + * + * ``` + * "heritage=external-dns,external-dns/owner=,external-dns/resource=" + * ``` + * + * When you have both DNSControl and external-dns managing the same DNS zone, conflicts + * can occur. DNSControl will try to delete records created by external-dns, and + * external-dns will recreate them, leading to an endless update cycle. + * + * ## How it works + * + * When `IGNORE_EXTERNAL_DNS` is enabled, DNSControl will: + * + * 1. Scan existing TXT records for the external-dns heritage marker (`heritage=external-dns`) + * 2. Parse the TXT record name to determine which DNS record it manages + * 3. Automatically ignore both the TXT ownership record and the corresponding DNS record + * + * External-dns creates TXT records with prefixes based on record type: + * - `a-` for A records + * - `aaaa-` for AAAA records + * - `cname-` for CNAME records + * - `ns-` for NS records + * - `mx-` for MX records + * - `srv-` for SRV records + * - `txt-` for TXT records (when external-dns manages TXT records) + * + * For example, if external-dns creates an A record at `myapp.example.com`, it will + * also create a TXT record at `a-myapp.example.com` containing the heritage information. + * + * ## Usage + * + * ```javascript + * // Default: detect standard external-dns prefixes (a-, cname-, etc.) + * D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER), + * IGNORE_EXTERNAL_DNS(), + * // Your static DNS records managed by DNSControl + * A("www", "1.2.3.4"), + * A("mail", "1.2.3.5"), + * MX("@", 10, "mail"), + * // Records created by external-dns (from Kubernetes Ingresses/Services) + * // will be automatically detected and ignored + * ); + * ``` + * + * ## Custom Prefix Support + * + * If your external-dns is configured with a custom `--txt-prefix` (as documented in the + * [external-dns TXT registry docs](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/registry/txt.md#prefixes-and-suffixes)), + * pass that prefix to `IGNORE_EXTERNAL_DNS()`: + * + * ```javascript + * // If external-dns is configured with --txt-prefix="extdns-" + * D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER), + * IGNORE_EXTERNAL_DNS("extdns-"), + * A("www", "1.2.3.4"), + * ); + * ``` + * + * This will match TXT records like `extdns-www`, `extdns-api`, etc. + * + * Without a prefix argument, it detects: + * - The default `%{record_type}-` format (prefixes like `a-`, `cname-`, etc.) + * - Legacy format (TXT record with same name as managed record) + * + * ## Example scenario + * + * Suppose you have: + * - A Kubernetes cluster running external-dns with `--txt-owner-id=my-cluster` + * - An Ingress resource that creates an A record for `myapp.example.com` pointing to `10.0.0.1` + * + * External-dns will create: + * 1. An A record: `myapp.example.com` → `10.0.0.1` + * 2. A TXT record: `a-myapp.example.com` → `"heritage=external-dns,external-dns/owner=my-cluster,external-dns/resource=ingress/default/myapp"` + * + * With `IGNORE_EXTERNAL_DNS` enabled, DNSControl will: + * - Detect the TXT record at `a-myapp.example.com` as an external-dns ownership record + * - Ignore both the TXT record and the A record at `myapp.example.com` + * - Only manage the records you explicitly define in your `dnsconfig.js` + * + * ## Comparison with other options + * + * | Feature | Use case | + * |---------|----------| + * | `IGNORE_EXTERNAL_DNS` | Automatically ignore all external-dns managed records | + * | `IGNORE("*.k8s", "A,AAAA,CNAME,TXT")` | Ignore records under a specific subdomain pattern | + * | `NO_PURGE` | Don't delete any records (less precise, records may accumulate) | + * + * ## Caveats + * + * ### TXT Registry Format + * + * This feature relies on external-dns's [TXT registry](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/registry/txt.md), + * which is the default registry type. The TXT record content format is well-documented: + * + * ``` + * "heritage=external-dns,external-dns/owner=,external-dns/resource=" + * ``` + * + * This feature detects the `heritage=external-dns` marker in TXT records to identify + * external-dns managed records. + * + * ### Custom Prefix Support + * + * This feature supports custom prefixes configured via external-dns's `--txt-prefix` flag. + * If you're using a custom prefix, pass it to `IGNORE_EXTERNAL_DNS()`: + * + * ```javascript + * // If external-dns uses --txt-prefix="extdns-" + * IGNORE_EXTERNAL_DNS("extdns-") + * + * // If external-dns uses --txt-prefix="myprefix-%{record_type}-" + * IGNORE_EXTERNAL_DNS("myprefix-") // The record type part is handled automatically + * + * // If external-dns uses --txt-prefix="extdns-%{record_type}." (period format) + * // This is recommended for apex domain support per external-dns docs + * IGNORE_EXTERNAL_DNS("extdns-") // Works with both hyphen and period format + * ``` + * + * Without a prefix argument, it detects: + * - Default format: `%{record_type}-` prefix (e.g., `a-`, `cname-`) + * - Legacy format: Same name as managed record (no prefix) + * + * #### Period Format for Apex Domains + * + * If you need external-dns to manage apex (root) domain records, the external-dns + * documentation recommends using a prefix with `%{record_type}` followed by a period: + * + * ```yaml + * # external-dns deployment args + * args: + * - --txt-prefix=extdns-%{record_type}. + * ``` + * + * This creates TXT records like `extdns-a.www` for the `www` A record, and `extdns-a` + * for the apex A record. DNSControl's `IGNORE_EXTERNAL_DNS` supports both formats: + * + * - Hyphen format: `extdns-a-www` (from `--txt-prefix=extdns-` with default `%{record_type}-`) + * - Period format: `extdns-a.www` (from `--txt-prefix=extdns-%{record_type}.`) + * + * **Note:** Suffix-based naming (`--txt-suffix`) is not currently supported. + * + * ### Unsupported Registries + * + * External-dns supports multiple registry types. This feature **only** supports: + * + * - ✅ **TXT registry** (default) - Stores metadata in TXT records + * + * The following registries are **not supported**: + * + * - ❌ **DynamoDB registry** - Stores metadata in AWS DynamoDB + * - ❌ **AWS-SD registry** - Stores metadata in AWS Service Discovery + * - ❌ **noop registry** - No metadata persistence + * + * ### Legacy TXT Format + * + * External-dns versions prior to v0.16 created TXT records without the record type + * prefix (e.g., `myapp.example.com` instead of `a-myapp.example.com`). This legacy + * format is supported but may match more records than intended since the record type + * cannot be determined. + * + * ## See also + * + * * [`IGNORE`](IGNORE.md) for manually ignoring specific records with glob patterns + * * [`NO_PURGE`](NO_PURGE.md) for preventing deletion of all unmanaged records + * * [External-dns documentation](https://github.com/kubernetes-sigs/external-dns) + * + * @see https://docs.dnscontrol.org/language-reference/domain-modifiers/ignore_external_dns + */ +declare function IGNORE_EXTERNAL_DNS(prefix?: string): DomainModifier; + /** * `IGNORE_NAME(a)` is the same as `IGNORE(a, "*", "*")`. * diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index 157324c37..dad8a6a95 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -50,6 +50,7 @@ * [FRAME](language-reference/domain-modifiers/FRAME.md) * [HTTPS](language-reference/domain-modifiers/HTTPS.md) * [IGNORE](language-reference/domain-modifiers/IGNORE.md) + * [IGNORE_EXTERNAL_DNS](language-reference/domain-modifiers/IGNORE_EXTERNAL_DNS.md) * [IGNORE_NAME](language-reference/domain-modifiers/IGNORE_NAME.md) * [IGNORE_TARGET](language-reference/domain-modifiers/IGNORE_TARGET.md) * [IMPORT_TRANSFORM](language-reference/domain-modifiers/IMPORT_TRANSFORM.md) diff --git a/documentation/language-reference/domain-modifiers/IGNORE_EXTERNAL_DNS.md b/documentation/language-reference/domain-modifiers/IGNORE_EXTERNAL_DNS.md new file mode 100644 index 000000000..fec32ca9c --- /dev/null +++ b/documentation/language-reference/domain-modifiers/IGNORE_EXTERNAL_DNS.md @@ -0,0 +1,196 @@ +--- +name: IGNORE_EXTERNAL_DNS +parameters: + - prefix +parameter_types: + prefix: string? +--- + +`IGNORE_EXTERNAL_DNS` makes DNSControl automatically detect and ignore DNS records +managed by Kubernetes external-dns. + +## Background + +[External-dns](https://github.com/kubernetes-sigs/external-dns) is a popular +Kubernetes controller that synchronizes exposed Kubernetes Services and Ingresses +with DNS providers. It creates DNS records automatically based on annotations on +your Kubernetes resources. + +External-dns uses TXT records to track ownership of the DNS records it manages. +These TXT records contain metadata in this format: + +``` +"heritage=external-dns,external-dns/owner=,external-dns/resource=" +``` + +When you have both DNSControl and external-dns managing the same DNS zone, conflicts +can occur. DNSControl will try to delete records created by external-dns, and +external-dns will recreate them, leading to an endless update cycle. + +## How it works + +When `IGNORE_EXTERNAL_DNS` is enabled, DNSControl will: + +1. Scan existing TXT records for the external-dns heritage marker (`heritage=external-dns`) +2. Parse the TXT record name to determine which DNS record it manages +3. Automatically ignore both the TXT ownership record and the corresponding DNS record + +External-dns creates TXT records with prefixes based on record type: +- `a-` for A records +- `aaaa-` for AAAA records +- `cname-` for CNAME records +- `ns-` for NS records +- `mx-` for MX records +- `srv-` for SRV records +- `txt-` for TXT records (when external-dns manages TXT records) + +For example, if external-dns creates an A record at `myapp.example.com`, it will +also create a TXT record at `a-myapp.example.com` containing the heritage information. + +## Usage + +{% code title="dnsconfig.js" %} +```javascript +// Default: detect standard external-dns prefixes (a-, cname-, etc.) +D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER), + IGNORE_EXTERNAL_DNS(), + // Your static DNS records managed by DNSControl + A("www", "1.2.3.4"), + A("mail", "1.2.3.5"), + MX("@", 10, "mail"), + // Records created by external-dns (from Kubernetes Ingresses/Services) + // will be automatically detected and ignored +); +``` +{% endcode %} + +## Custom Prefix Support + +If your external-dns is configured with a custom `--txt-prefix` (as documented in the +[external-dns TXT registry docs](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/registry/txt.md#prefixes-and-suffixes)), +pass that prefix to `IGNORE_EXTERNAL_DNS()`: + +{% code title="dnsconfig.js" %} +```javascript +// If external-dns is configured with --txt-prefix="extdns-" +D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER), + IGNORE_EXTERNAL_DNS("extdns-"), + A("www", "1.2.3.4"), +); +``` +{% endcode %} + +This will match TXT records like `extdns-www`, `extdns-api`, etc. + +Without a prefix argument, it detects: +- The default `%{record_type}-` format (prefixes like `a-`, `cname-`, etc.) +- Legacy format (TXT record with same name as managed record) + +## Example scenario + +Suppose you have: +- A Kubernetes cluster running external-dns with `--txt-owner-id=my-cluster` +- An Ingress resource that creates an A record for `myapp.example.com` pointing to `10.0.0.1` + +External-dns will create: +1. An A record: `myapp.example.com` → `10.0.0.1` +2. A TXT record: `a-myapp.example.com` → `"heritage=external-dns,external-dns/owner=my-cluster,external-dns/resource=ingress/default/myapp"` + +With `IGNORE_EXTERNAL_DNS` enabled, DNSControl will: +- Detect the TXT record at `a-myapp.example.com` as an external-dns ownership record +- Ignore both the TXT record and the A record at `myapp.example.com` +- Only manage the records you explicitly define in your `dnsconfig.js` + +## Comparison with other options + +| Feature | Use case | +|---------|----------| +| `IGNORE_EXTERNAL_DNS` | Automatically ignore all external-dns managed records | +| `IGNORE("*.k8s", "A,AAAA,CNAME,TXT")` | Ignore records under a specific subdomain pattern | +| `NO_PURGE` | Don't delete any records (less precise, records may accumulate) | + +## Caveats + +### One per domain + +Only one `IGNORE_EXTERNAL_DNS()` should be used per domain. If you call it multiple +times, the last prefix wins. If you have multiple external-dns instances with +different prefixes managing the same zone, use `IGNORE()` patterns for additional +prefixes. + +### TXT Registry Format + +This feature relies on external-dns's [TXT registry](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/registry/txt.md), +which is the default registry type. The TXT record content format is well-documented: + +``` +"heritage=external-dns,external-dns/owner=,external-dns/resource=" +``` + +This feature detects the `heritage=external-dns` marker in TXT records to identify +external-dns managed records. + +### Custom Prefix Support + +This feature supports custom prefixes configured via external-dns's `--txt-prefix` flag. +If you're using a custom prefix, pass it to `IGNORE_EXTERNAL_DNS()`: + +```javascript +// If external-dns uses --txt-prefix="extdns-" +IGNORE_EXTERNAL_DNS("extdns-") + +// If external-dns uses --txt-prefix="myprefix-%{record_type}-" +IGNORE_EXTERNAL_DNS("myprefix-") // The record type part is handled automatically + +// If external-dns uses --txt-prefix="extdns-%{record_type}." (period format) +// This is recommended for apex domain support per external-dns docs +IGNORE_EXTERNAL_DNS("extdns-") // Works with both hyphen and period format +``` + +Without a prefix argument, it detects: +- Default format: `%{record_type}-` prefix (e.g., `a-`, `cname-`) +- Legacy format: Same name as managed record (no prefix) + +#### Period Format for Apex Domains + +If you need external-dns to manage apex (root) domain records, the external-dns +documentation recommends using a prefix with `%{record_type}` followed by a period: + +```yaml +# external-dns deployment args +args: + - --txt-prefix=extdns-%{record_type}. +``` + +This creates TXT records like `extdns-a.www` for the `www` A record, and `extdns-a` +for the apex A record. DNSControl's `IGNORE_EXTERNAL_DNS` supports both formats: + +- Hyphen format: `extdns-a-www` (from `--txt-prefix=extdns-` with default `%{record_type}-`) +- Period format: `extdns-a.www` (from `--txt-prefix=extdns-%{record_type}.`) + +**Note:** Suffix-based naming (`--txt-suffix`) is not currently supported. + +### Unsupported Registries + +External-dns supports multiple registry types. This feature **only** supports: + +- ✅ **TXT registry** (default) - Stores metadata in TXT records + +The following registries are **not supported**: + +- ❌ **DynamoDB registry** - Stores metadata in AWS DynamoDB +- ❌ **AWS-SD registry** - Stores metadata in AWS Service Discovery +- ❌ **noop registry** - No metadata persistence + +### Legacy TXT Format + +External-dns versions prior to v0.16 created TXT records without the record type +prefix (e.g., `myapp.example.com` instead of `a-myapp.example.com`). This legacy +format is supported but may match more records than intended since the record type +cannot be determined. + +## See also + +* [`IGNORE`](IGNORE.md) for manually ignoring specific records with glob patterns +* [`NO_PURGE`](NO_PURGE.md) for preventing deletion of all unmanaged records +* [External-dns documentation](https://github.com/kubernetes-sigs/external-dns) diff --git a/models/domain.go b/models/domain.go index 845b1b460..fa8bd9ba6 100644 --- a/models/domain.go +++ b/models/domain.go @@ -40,6 +40,9 @@ type DomainConfig struct { Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` // IGNORE() UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` // DISABLE_IGNORE_SAFETY_CHECK + IgnoreExternalDNS bool `json:"ignore_external_dns,omitempty"` // IGNORE_EXTERNAL_DNS + ExternalDNSPrefix string `json:"external_dns_prefix,omitempty"` // IGNORE_EXTERNAL_DNS prefix + AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off" // DNSSEC bool `json:"dnssec,omitempty"` diff --git a/pkg/diff2/diff2.go b/pkg/diff2/diff2.go index 4a34e8725..6ffa9b246 100644 --- a/pkg/diff2/diff2.go +++ b/pkg/diff2/diff2.go @@ -241,6 +241,8 @@ func byHelperStruct(fn func(cc *CompareConfig) (ChangeList, int), existing model dc.Unmanaged, dc.UnmanagedUnsafe, dc.KeepUnknown, + dc.IgnoreExternalDNS, + dc.ExternalDNSPrefix, ) if err != nil { return ByResults{}, err diff --git a/pkg/diff2/externaldns.go b/pkg/diff2/externaldns.go new file mode 100644 index 000000000..6eda1cf69 --- /dev/null +++ b/pkg/diff2/externaldns.go @@ -0,0 +1,230 @@ +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=,external-dns/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) +} diff --git a/pkg/diff2/externaldns_test.go b/pkg/diff2/externaldns_test.go new file mode 100644 index 000000000..1ec76d2b9 --- /dev/null +++ b/pkg/diff2/externaldns_test.go @@ -0,0 +1,528 @@ +package diff2 + +import ( + "testing" + + "github.com/StackExchange/dnscontrol/v4/models" +) + +func makeTestRecord(name, rtype, target, domain string) *models.RecordConfig { + rc := &models.RecordConfig{ + Type: rtype, + } + rc.SetLabel(name, domain) + if rtype == "TXT" { + rc.SetTargetTXT(target) + } else { + rc.SetTarget(target) + } + return rc +} + +func TestIsExternalDNSTxtRecord(t *testing.T) { + domain := "example.com" + + tests := []struct { + name string + record *models.RecordConfig + wantIsExtDNS bool + wantLabel string + wantRecordType string + }{ + { + name: "external-dns A record TXT", + record: makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=my-cluster,external-dns/resource=ingress/default/myapp", domain), + wantIsExtDNS: true, + wantLabel: "myapp", + wantRecordType: "A", + }, + { + name: "external-dns AAAA record TXT", + record: makeTestRecord("aaaa-myapp", "TXT", "heritage=external-dns,external-dns/owner=my-cluster", domain), + wantIsExtDNS: true, + wantLabel: "myapp", + wantRecordType: "AAAA", + }, + { + name: "external-dns CNAME record TXT", + record: makeTestRecord("cname-www", "TXT", "heritage=external-dns,external-dns/owner=default", domain), + wantIsExtDNS: true, + wantLabel: "www", + wantRecordType: "CNAME", + }, + { + name: "external-dns apex A record TXT", + record: makeTestRecord("a-", "TXT", "heritage=external-dns,external-dns/owner=k8s", domain), + wantIsExtDNS: true, + wantLabel: "@", + wantRecordType: "A", + }, + { + name: "non-external-dns TXT record", + record: makeTestRecord("myapp", "TXT", "some random txt content", domain), + wantIsExtDNS: false, + }, + { + name: "A record (not TXT)", + record: makeTestRecord("myapp", "A", "1.2.3.4", domain), + wantIsExtDNS: false, + }, + { + name: "SPF record", + record: makeTestRecord("@", "TXT", "v=spf1 include:_spf.google.com ~all", domain), + wantIsExtDNS: false, + }, + { + name: "DKIM record", + record: makeTestRecord("selector._domainkey", "TXT", "v=DKIM1; k=rsa; p=MIGfMA0G...", domain), + wantIsExtDNS: false, + }, + { + name: "external-dns with quoted heritage", + record: makeTestRecord("a-test", "TXT", "\"heritage=external-dns,external-dns/owner=test\"", domain), + wantIsExtDNS: true, + wantLabel: "test", + wantRecordType: "A", + }, + { + name: "external-dns legacy format (no prefix)", + record: makeTestRecord("legacy-app", "TXT", "heritage=external-dns,external-dns/owner=old-cluster", domain), + wantIsExtDNS: true, + wantLabel: "legacy-app", + wantRecordType: "", // Empty means match any type + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIsExtDNS, gotInfo := isExternalDNSTxtRecord(tt.record, domain, "") + + if gotIsExtDNS != tt.wantIsExtDNS { + t.Errorf("isExternalDNSTxtRecord() isExtDNS = %v, want %v", gotIsExtDNS, tt.wantIsExtDNS) + } + + if tt.wantIsExtDNS { + if gotInfo == nil { + t.Errorf("isExternalDNSTxtRecord() returned nil info for external-dns record") + return + } + if gotInfo.Label != tt.wantLabel { + t.Errorf("isExternalDNSTxtRecord() label = %q, want %q", gotInfo.Label, tt.wantLabel) + } + if gotInfo.RecordType != tt.wantRecordType { + t.Errorf("isExternalDNSTxtRecord() recordType = %q, want %q", gotInfo.RecordType, tt.wantRecordType) + } + } + }) + } +} + +func TestParseExternalDNSTxtLabel(t *testing.T) { + tests := []struct { + name string + label string + wantLabel string + wantRecordType string + }{ + { + name: "A record prefix", + label: "a-myapp", + wantLabel: "myapp", + wantRecordType: "A", + }, + { + name: "AAAA record prefix", + label: "aaaa-myapp", + wantLabel: "myapp", + wantRecordType: "AAAA", + }, + { + name: "CNAME record prefix", + label: "cname-www", + wantLabel: "www", + wantRecordType: "CNAME", + }, + { + name: "NS record prefix", + label: "ns-subdomain", + wantLabel: "subdomain", + wantRecordType: "NS", + }, + { + name: "MX record prefix", + label: "mx-mail", + wantLabel: "mail", + wantRecordType: "MX", + }, + { + name: "apex domain (A)", + label: "a-", + wantLabel: "@", + wantRecordType: "A", + }, + { + name: "uppercase prefix (should handle case)", + label: "A-myapp", + wantLabel: "myapp", + wantRecordType: "A", + }, + { + name: "no recognized prefix (legacy)", + label: "myapp", + wantLabel: "myapp", + wantRecordType: "", + }, + { + name: "subdomain with dots in name", + label: "a-sub.domain", + wantLabel: "sub.domain", + wantRecordType: "A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseExternalDNSTxtLabel(tt.label, "") + + if got.Label != tt.wantLabel { + t.Errorf("parseExternalDNSTxtLabel() Label = %q, want %q", got.Label, tt.wantLabel) + } + if got.RecordType != tt.wantRecordType { + t.Errorf("parseExternalDNSTxtLabel() RecordType = %q, want %q", got.RecordType, tt.wantRecordType) + } + }) + } +} + +func TestFindExternalDNSManagedRecords(t *testing.T) { + domain := "example.com" + + existing := models.Records{ + // External-dns managed records + makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=cluster1", domain), + makeTestRecord("myapp", "A", "10.0.0.1", domain), + makeTestRecord("cname-www", "TXT", "heritage=external-dns,external-dns/owner=cluster1", domain), + makeTestRecord("www", "CNAME", "myapp.example.com.", domain), + // Non-external-dns records + makeTestRecord("static", "A", "1.2.3.4", domain), + makeTestRecord("@", "TXT", "v=spf1 -all", domain), + makeTestRecord("@", "MX", "mail.example.com.", domain), + } + + managed := findExternalDNSManagedRecords(existing, domain, "") + + // Check that expected keys are present + expectedKeys := []string{ + "a-myapp:TXT", // The TXT record itself + "myapp:A", // The A record it manages + "cname-www:TXT", // The TXT record itself + "www:CNAME", // The CNAME record it manages + } + + for _, key := range expectedKeys { + if !managed[key] { + t.Errorf("Expected key %q to be marked as managed", key) + } + } + + // Check that non-external-dns records are not marked + notExpected := []string{ + "static:A", + "@:TXT", + "@:MX", + } + + for _, key := range notExpected { + if managed[key] { + t.Errorf("Key %q should not be marked as managed", key) + } + } +} + +func TestGetExternalDNSIgnoredRecords(t *testing.T) { + domain := "example.com" + + existing := models.Records{ + // External-dns managed records + makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=cluster1", domain), + makeTestRecord("myapp", "A", "10.0.0.1", domain), + // Non-external-dns records + makeTestRecord("static", "A", "1.2.3.4", domain), + makeTestRecord("@", "TXT", "v=spf1 -all", domain), + } + + ignored := GetExternalDNSIgnoredRecords(existing, domain, "") + + if len(ignored) != 2 { + t.Errorf("Expected 2 ignored records, got %d", len(ignored)) + } + + // Verify the ignored records + foundTXT := false + foundA := false + for _, rec := range ignored { + if rec.GetLabel() == "a-myapp" && rec.Type == "TXT" { + foundTXT = true + } + if rec.GetLabel() == "myapp" && rec.Type == "A" { + foundA = true + } + } + + if !foundTXT { + t.Error("Expected TXT record a-myapp to be ignored") + } + if !foundA { + t.Error("Expected A record myapp to be ignored") + } +} + +func TestGetExternalDNSIgnoredRecords_NoExternalDNS(t *testing.T) { + domain := "example.com" + + existing := models.Records{ + makeTestRecord("static", "A", "1.2.3.4", domain), + makeTestRecord("@", "TXT", "v=spf1 -all", domain), + makeTestRecord("www", "CNAME", "static.example.com.", domain), + } + + ignored := GetExternalDNSIgnoredRecords(existing, domain, "") + + if len(ignored) != 0 { + t.Errorf("Expected 0 ignored records when no external-dns records exist, got %d", len(ignored)) + } +} + +func TestGetExternalDNSIgnoredRecords_LegacyFormat(t *testing.T) { + domain := "example.com" + + // Legacy format: TXT record has same name as the record it manages (no type prefix) + existing := models.Records{ + makeTestRecord("legacyapp", "TXT", "heritage=external-dns,external-dns/owner=old-cluster", domain), + makeTestRecord("legacyapp", "A", "10.0.0.1", domain), + makeTestRecord("legacyapp", "AAAA", "::1", domain), + } + + ignored := GetExternalDNSIgnoredRecords(existing, domain, "") + + // Legacy format should match the TXT and common record types + if len(ignored) < 3 { + t.Errorf("Expected at least 3 ignored records for legacy format, got %d", len(ignored)) + } +} + +func TestGetExternalDNSIgnoredRecords_CustomPrefix(t *testing.T) { + domain := "example.com" + + // Custom prefix format: e.g., "extdns-www" for "www" record + existing := models.Records{ + makeTestRecord("extdns-www", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + makeTestRecord("www", "A", "10.0.0.1", domain), + makeTestRecord("extdns-api", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + makeTestRecord("api", "CNAME", "app.example.com.", domain), + // Non-external-dns records + makeTestRecord("static", "A", "1.2.3.4", domain), + } + + // Without custom prefix, only the TXT records themselves should be detected + // (but the A/CNAME won't be linked because "extdns-" isn't a known type prefix) + ignoredDefault := GetExternalDNSIgnoredRecords(existing, domain, "") + + // With custom prefix, both TXT and their managed records should be detected + ignoredCustom := GetExternalDNSIgnoredRecords(existing, domain, "extdns-") + + // Custom prefix should find more records + if len(ignoredCustom) <= len(ignoredDefault) { + t.Errorf("Expected custom prefix to find more records: default=%d, custom=%d", + len(ignoredDefault), len(ignoredCustom)) + } + + // Should find: extdns-www:TXT, www:A/AAAA/CNAME/etc, extdns-api:TXT, api:A/AAAA/CNAME/etc + if len(ignoredCustom) < 4 { + t.Errorf("Expected at least 4 ignored records with custom prefix, got %d", len(ignoredCustom)) + } +} + +func TestParseExternalDNSTxtLabel_CustomPrefix(t *testing.T) { + tests := []struct { + name string + label string + customPrefix string + wantLabel string + wantRecordType string + }{ + { + name: "custom prefix with record type", + label: "extdns-a-myapp", + customPrefix: "extdns-", + wantLabel: "myapp", + wantRecordType: "A", + }, + { + name: "custom prefix without record type", + label: "extdns-www", + customPrefix: "extdns-", + wantLabel: "www", + wantRecordType: "", // No record type in this format + }, + { + name: "custom prefix apex domain", + label: "extdns-", + customPrefix: "extdns-", + wantLabel: "@", + wantRecordType: "", + }, + { + name: "prefix not found - fallback to legacy", + label: "other-www", + customPrefix: "extdns-", + wantLabel: "other-www", + wantRecordType: "", + }, + // Period format tests (--txt-prefix=extdns-%{record_type}.) + { + name: "period format A record", + label: "extdns-a.myapp", + customPrefix: "extdns-", + wantLabel: "myapp", + wantRecordType: "A", + }, + { + name: "period format AAAA record", + label: "extdns-aaaa.myapp", + customPrefix: "extdns-", + wantLabel: "myapp", + wantRecordType: "AAAA", + }, + { + name: "period format CNAME record", + label: "extdns-cname.www", + customPrefix: "extdns-", + wantLabel: "www", + wantRecordType: "CNAME", + }, + { + name: "period format apex A record", + label: "extdns-a", + customPrefix: "extdns-", + wantLabel: "@", + wantRecordType: "A", + }, + { + name: "period format apex AAAA record", + label: "extdns-aaaa", + customPrefix: "extdns-", + wantLabel: "@", + wantRecordType: "AAAA", + }, + { + name: "period format apex CNAME record", + label: "extdns-cname", + customPrefix: "extdns-", + wantLabel: "@", + wantRecordType: "CNAME", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseExternalDNSTxtLabel(tt.label, tt.customPrefix) + + if got.Label != tt.wantLabel { + t.Errorf("parseExternalDNSTxtLabel() Label = %q, want %q", got.Label, tt.wantLabel) + } + if got.RecordType != tt.wantRecordType { + t.Errorf("parseExternalDNSTxtLabel() RecordType = %q, want %q", got.RecordType, tt.wantRecordType) + } + }) + } +} + +// TestGetExternalDNSIgnoredRecords_PeriodFormat tests the period format used when +// external-dns is configured with --txt-prefix=prefix-%{record_type}. +// This creates TXT records like "extdns-a.www" for "www" A record. +func TestGetExternalDNSIgnoredRecords_PeriodFormat(t *testing.T) { + domain := "example.com" + + existing := models.Records{ + // Period format TXT records (from --txt-prefix=extdns-%{record_type}.) + makeTestRecord("extdns-a.www", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + makeTestRecord("www", "A", "10.0.0.1", domain), + makeTestRecord("extdns-cname.api", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + makeTestRecord("api", "CNAME", "app.example.com.", domain), + // Non-external-dns records + makeTestRecord("static", "A", "1.2.3.4", domain), + } + + ignored := GetExternalDNSIgnoredRecords(existing, domain, "extdns-") + + // Should find: extdns-a.www:TXT, www:A, extdns-cname.api:TXT, api:CNAME + if len(ignored) < 4 { + t.Errorf("Expected at least 4 ignored records with period format, got %d", len(ignored)) + } + + // Verify specific records are ignored + found := make(map[string]bool) + for _, rec := range ignored { + key := rec.GetLabel() + ":" + rec.Type + found[key] = true + } + + expectedKeys := []string{"extdns-a.www:TXT", "www:A", "extdns-cname.api:TXT", "api:CNAME"} + for _, key := range expectedKeys { + if !found[key] { + t.Errorf("Expected record %q to be ignored", key) + } + } +} + +// TestGetExternalDNSIgnoredRecords_PeriodFormatApex tests apex domain detection +// with the period format (--txt-prefix=prefix-%{record_type}.). +// For apex domains, the TXT label becomes "extdns-a" (just prefix + record type). +func TestGetExternalDNSIgnoredRecords_PeriodFormatApex(t *testing.T) { + domain := "example.com" + + existing := models.Records{ + // Period format apex A record: TXT at "extdns-a" manages "@" A record + makeTestRecord("extdns-a", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + makeTestRecord("@", "A", "10.0.0.1", domain), + // Period format apex AAAA record + makeTestRecord("extdns-aaaa", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + makeTestRecord("@", "AAAA", "::1", domain), + // Non-external-dns apex records (should not be ignored) + makeTestRecord("@", "MX", "mail.example.com.", domain), + makeTestRecord("@", "TXT", "v=spf1 -all", domain), + } + + ignored := GetExternalDNSIgnoredRecords(existing, domain, "extdns-") + + // Should find: extdns-a:TXT, @:A, extdns-aaaa:TXT, @:AAAA + if len(ignored) != 4 { + t.Errorf("Expected 4 ignored records for apex with period format, got %d", len(ignored)) + for _, rec := range ignored { + t.Logf(" ignored: %s:%s", rec.GetLabel(), rec.Type) + } + } + + // Verify specific records are ignored + found := make(map[string]bool) + for _, rec := range ignored { + key := rec.GetLabel() + ":" + rec.Type + found[key] = true + } + + expectedKeys := []string{"extdns-a:TXT", "@:A", "extdns-aaaa:TXT", "@:AAAA"} + for _, key := range expectedKeys { + if !found[key] { + t.Errorf("Expected record %q to be ignored", key) + } + } + + // Verify MX and SPF TXT are NOT ignored + notExpectedKeys := []string{"@:MX", "@:TXT"} + for _, key := range notExpectedKeys { + if found[key] { + t.Errorf("Record %q should NOT be ignored", key) + } + } +} diff --git a/pkg/diff2/handsoff.go b/pkg/diff2/handsoff.go index e22669b13..98f20f2eb 100644 --- a/pkg/diff2/handsoff.go +++ b/pkg/diff2/handsoff.go @@ -106,6 +106,8 @@ func handsoff( unmanagedConfigs []*models.UnmanagedConfig, unmanagedSafely bool, noPurge bool, + ignoreExternalDNS bool, + externalDNSPrefix string, ) (models.Records, []string, error) { var msgs []string @@ -120,6 +122,16 @@ func handsoff( punct = "." } + // Process IGNORE_EXTERNAL_DNS feature: + var externalDNSIgnored models.Records + if ignoreExternalDNS { + externalDNSIgnored = GetExternalDNSIgnoredRecords(existing, domain, externalDNSPrefix) + if len(externalDNSIgnored) != 0 { + msgs = append(msgs, fmt.Sprintf("%d records not being deleted because of IGNORE_EXTERNAL_DNS%s", len(externalDNSIgnored), punct)) + msgs = append(msgs, reportSkips(externalDNSIgnored, !printer.SkinnyReport)...) + } + } + // Process IGNORE*() and NO_PURGE features: ignorable, foreign, err := processIgnoreAndNoPurge(domain, existing, desired, absences, unmanagedConfigs, noPurge) if err != nil { @@ -147,9 +159,25 @@ func handsoff( } } + // Check for conflicts between desired records and external-dns managed records. + // This warns users when they define a record that external-dns is also managing. + if ignoreExternalDNS && len(externalDNSIgnored) > 0 { + externalDNSConflicts := findExternalDNSConflicts(desired, externalDNSIgnored) + if len(externalDNSConflicts) != 0 { + msgs = append(msgs, fmt.Sprintf("WARNING: %d records are defined in your config but also managed by external-dns:", len(externalDNSConflicts))) + for _, r := range externalDNSConflicts { + msgs = append(msgs, fmt.Sprintf(" %s %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetCombined())) + } + msgs = append(msgs, "Consider removing these from your config or from external-dns to avoid conflicts.") + } + // Filter out conflicts from externalDNSIgnored to avoid duplicates in desired + externalDNSIgnored = filterOutConflicts(externalDNSIgnored, externalDNSConflicts) + } + // Add the ignored/foreign items to the desired list so they are not deleted: desired = append(desired, ignorable...) desired = append(desired, foreign...) + desired = append(desired, externalDNSIgnored...) return desired, msgs, nil } @@ -285,3 +313,50 @@ func matchTarget(targetGlob glob.Glob, targetName string) bool { } return targetGlob.Match(targetName) } + +// findExternalDNSConflicts returns records that appear in both desired and externalDNSIgnored. +// This helps identify when a user has defined a record in their config that is also +// being managed by external-dns. +func findExternalDNSConflicts(desired, externalDNSIgnored models.Records) models.Records { + // Build a map of desired records keyed by label:type + desiredMap := make(map[string]bool) + for _, rec := range desired { + key := rec.GetLabel() + ":" + rec.Type + desiredMap[key] = true + } + + // Find any external-dns ignored records that are also in desired + var conflicts models.Records + for _, rec := range externalDNSIgnored { + key := rec.GetLabel() + ":" + rec.Type + if desiredMap[key] { + conflicts = append(conflicts, rec) + } + } + return conflicts +} + +// filterOutConflicts removes records from externalDNSIgnored that are in conflicts. +// This prevents duplicates when appending externalDNSIgnored to desired. +func filterOutConflicts(externalDNSIgnored, conflicts models.Records) models.Records { + if len(conflicts) == 0 { + return externalDNSIgnored + } + + // Build a set of conflict keys + conflictSet := make(map[string]bool) + for _, rec := range conflicts { + key := rec.GetLabel() + ":" + rec.Type + conflictSet[key] = true + } + + // Filter out conflicts + var filtered models.Records + for _, rec := range externalDNSIgnored { + key := rec.GetLabel() + ":" + rec.Type + if !conflictSet[key] { + filtered = append(filtered, rec) + } + } + return filtered +} diff --git a/pkg/diff2/handsoff_test.go b/pkg/diff2/handsoff_test.go index 565ab2a94..ebf2b55c4 100644 --- a/pkg/diff2/handsoff_test.go +++ b/pkg/diff2/handsoff_test.go @@ -237,3 +237,204 @@ _2222222222222222.cr CNAME _333333.nnn.acm-validations.aws. FOREIGN: `) } + +// Test_ignore_external_dns tests the IGNORE_EXTERNAL_DNS feature +// using the full handsoff() function. +func Test_ignore_external_dns(t *testing.T) { + domain := "f.com" + + // Existing zone has external-dns managed records + existing := models.Records{ + // External-dns TXT ownership record + makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=k8s-cluster", domain), + // The A record managed by external-dns + makeTestRecord("myapp", "A", "10.0.0.1", domain), + // Static record not managed by external-dns + makeTestRecord("static", "A", "1.2.3.4", domain), + // Another external-dns managed record + makeTestRecord("cname-api", "TXT", "heritage=external-dns,external-dns/owner=k8s-cluster", domain), + makeTestRecord("api", "CNAME", "myapp.f.com.", domain), + } + + // Desired only has the static record + desired := models.Records{ + makeTestRecord("static", "A", "1.2.3.4", domain), + } + + // Call handsoff with IGNORE_EXTERNAL_DNS enabled + result, msgs, err := handsoff( + domain, + existing, + desired, + nil, // absences + nil, // unmanagedConfigs + false, // unmanagedSafely + false, // noPurge + true, // ignoreExternalDNS + "", // externalDNSPrefix (empty = default) + ) + if err != nil { + t.Fatal(err) + } + + // Check that external-dns records are in the result (so they won't be deleted) + foundMyappA := false + foundMyappTXT := false + foundApiCNAME := false + foundApiTXT := false + foundStatic := false + + for _, rec := range result { + switch { + case rec.GetLabel() == "myapp" && rec.Type == "A": + foundMyappA = true + case rec.GetLabel() == "a-myapp" && rec.Type == "TXT": + foundMyappTXT = true + case rec.GetLabel() == "api" && rec.Type == "CNAME": + foundApiCNAME = true + case rec.GetLabel() == "cname-api" && rec.Type == "TXT": + foundApiTXT = true + case rec.GetLabel() == "static" && rec.Type == "A": + foundStatic = true + } + } + + if !foundMyappA { + t.Error("Expected myapp A record to be preserved") + } + if !foundMyappTXT { + t.Error("Expected a-myapp TXT record to be preserved") + } + if !foundApiCNAME { + t.Error("Expected api CNAME record to be preserved") + } + if !foundApiTXT { + t.Error("Expected cname-api TXT record to be preserved") + } + if !foundStatic { + t.Error("Expected static A record to be preserved") + } + + // Check that we got a message about external-dns records + foundMsg := false + for _, msg := range msgs { + if strings.Contains(msg, "IGNORE_EXTERNAL_DNS") { + foundMsg = true + break + } + } + if !foundMsg { + t.Error("Expected message about IGNORE_EXTERNAL_DNS records") + } +} + +// Test_ignore_external_dns_custom_prefix tests IGNORE_EXTERNAL_DNS with custom prefix +func Test_ignore_external_dns_custom_prefix(t *testing.T) { + domain := "f.com" + + // Existing zone has external-dns managed records with custom prefix "extdns-" + existing := models.Records{ + // External-dns TXT ownership record with custom prefix + makeTestRecord("extdns-www", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain), + // The A record managed by external-dns + makeTestRecord("www", "A", "10.0.0.1", domain), + // Static record + makeTestRecord("static", "A", "1.2.3.4", domain), + } + + // Desired only has the static record + desired := models.Records{ + makeTestRecord("static", "A", "1.2.3.4", domain), + } + + // Call handsoff with custom prefix + result, _, err := handsoff( + domain, + existing, + desired, + nil, // absences + nil, // unmanagedConfigs + false, // unmanagedSafely + false, // noPurge + true, // ignoreExternalDNS + "extdns-", // externalDNSPrefix + ) + if err != nil { + t.Fatal(err) + } + + // Check that external-dns records with custom prefix are preserved + foundWwwA := false + foundWwwTXT := false + + for _, rec := range result { + switch { + case rec.GetLabel() == "www" && rec.Type == "A": + foundWwwA = true + case rec.GetLabel() == "extdns-www" && rec.Type == "TXT": + foundWwwTXT = true + } + } + + if !foundWwwA { + t.Error("Expected www A record to be preserved with custom prefix") + } + if !foundWwwTXT { + t.Error("Expected extdns-www TXT record to be preserved with custom prefix") + } +} + +// Test_ignore_external_dns_conflict tests conflict detection +func Test_ignore_external_dns_conflict(t *testing.T) { + domain := "f.com" + + // Existing zone has external-dns managed record + existing := models.Records{ + makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=k8s-cluster", domain), + makeTestRecord("myapp", "A", "10.0.0.1", domain), + } + + // Desired ALSO has myapp - this is a conflict! + desired := models.Records{ + makeTestRecord("myapp", "A", "192.168.1.1", domain), // Different IP + } + + // Call handsoff with IGNORE_EXTERNAL_DNS enabled + result, msgs, err := handsoff( + domain, + existing, + desired, + nil, // absences + nil, // unmanagedConfigs + false, // unmanagedSafely + false, // noPurge + true, // ignoreExternalDNS + "", // externalDNSPrefix + ) + if err != nil { + t.Fatal(err) + } + + // Should get a warning about the conflict + foundConflictWarning := false + for _, msg := range msgs { + if strings.Contains(msg, "WARNING") && strings.Contains(msg, "external-dns") { + foundConflictWarning = true + break + } + } + if !foundConflictWarning { + t.Error("Expected warning about conflict between desired and external-dns records") + } + + // The desired record should be in result (not duplicated) + myappCount := 0 + for _, rec := range result { + if rec.GetLabel() == "myapp" && rec.Type == "A" { + myappCount++ + } + } + if myappCount != 1 { + t.Errorf("Expected exactly 1 myapp A record in result, got %d", myappCount) + } +} diff --git a/pkg/js/helpers.js b/pkg/js/helpers.js index 7c00479bf..46260624f 100644 --- a/pkg/js/helpers.js +++ b/pkg/js/helpers.js @@ -333,16 +333,24 @@ var AKAMAICDN = recordBuilder('AKAMAICDN'); // AKAMAITLC(name, answer_type, target, recordModifiers...) var AKAMAITLC = recordBuilder('AKAMAITLC', { - args: [ - ['name', _.isString], - ['answer_type', function(value) { return _.isString(value) && ['DUAL', 'A', 'AAAA'].indexOf(value) !== -1; }], - ['target', _.isString], - ], - transform: function (record, args, modifier) { - record.name = args.name; - record.answer_type = args.answer_type; - record.target = args.target; - }, + args: [ + ['name', _.isString], + [ + 'answer_type', + function (value) { + return ( + _.isString(value) && + ['DUAL', 'A', 'AAAA'].indexOf(value) !== -1 + ); + }, + ], + ['target', _.isString], + ], + transform: function (record, args, modifier) { + record.name = args.name; + record.answer_type = args.answer_type; + record.target = args.target; + }, }); // ALIAS(name,target, recordModifiers...) @@ -683,20 +691,20 @@ var TXT = recordBuilder('TXT', { }); var LUA = recordBuilder('LUA', { - args: [ - ['name', _.isString], - ['rtype', _.isString], - ['target', isStringOrArray], - ], - transform: function (record, args, modifiers) { - record.name = args.name; - record.luartype = args.rtype.toUpperCase(); - if (_.isString(args.target)) { - record.target = args.target; - } else { - record.target = args.target.join(''); - } - }, + args: [ + ['name', _.isString], + ['rtype', _.isString], + ['target', isStringOrArray], + ], + transform: function (record, args, modifiers) { + record.name = args.name; + record.luartype = args.rtype.toUpperCase(); + if (_.isString(args.target)) { + record.target = args.target; + } else { + record.target = args.target.join(''); + } + }, }); // Parses coordinates of the form 41°24'12.2"N 2°10'26.5"E @@ -858,15 +866,15 @@ function locStringBuilder(record, args) { (args.alt < -100000 ? -100000 : args.alt > 42849672.95 - ? 42849672.95 - : args.alt.toString()) + 'm'; + ? 42849672.95 + : args.alt.toString()) + 'm'; precisionbuffer += ' ' + (args.siz > 90000000 ? 90000000 : args.siz < 0 - ? 0 - : args.siz.toString()) + + ? 0 + : args.siz.toString()) + 'm'; precisionbuffer += ' ' + @@ -906,8 +914,8 @@ function locDMSBuilder(record, args) { record.localtitude > 4294967295 ? 4294967295 : record.localtitude < 0 - ? 0 - : record.localtitude; + ? 0 + : record.localtitude; // Size record.locsize = getENotationInt(args.siz); // Horizontal Precision @@ -1163,6 +1171,29 @@ function NO_PURGE(d) { d.KeepUnknown = true; } +// IGNORE_EXTERNAL_DNS(prefix) +// When enabled, DNSControl will automatically detect TXT records created by +// Kubernetes external-dns and ignore both the TXT records and the corresponding +// DNS records they manage. External-dns creates TXT records with content like: +// "heritage=external-dns,external-dns/owner=,external-dns/resource=" +// This allows DNSControl to coexist with external-dns in the same zone. +// +// Optional prefix parameter: If your external-dns is configured with a custom +// --txt-prefix (e.g., "extdns-"), pass it here to detect those records. +// Without a prefix, it detects the default format ("%{record_type}-" prefixes like "a-", "cname-"). +// +// Usage: +// IGNORE_EXTERNAL_DNS() // Use default detection (a-, cname-, etc.) +// IGNORE_EXTERNAL_DNS("extdns-") // Custom prefix +function IGNORE_EXTERNAL_DNS(prefix) { + return function (d) { + d.ignore_external_dns = true; + if (prefix) { + d.external_dns_prefix = prefix; + } + }; +} + // ENSURE_ABSENT_REC() // Usage: A("foo", "1.2.3.4", ENSURE_ABSENT_REC()) function ENSURE_ABSENT_REC() { diff --git a/pkg/js/parse_tests/058-ignore-external-dns.js b/pkg/js/parse_tests/058-ignore-external-dns.js new file mode 100644 index 000000000..28746de94 --- /dev/null +++ b/pkg/js/parse_tests/058-ignore-external-dns.js @@ -0,0 +1,13 @@ +// Test IGNORE_EXTERNAL_DNS domain modifier +// Default usage (no prefix) +D("extdns-default.com", "none", IGNORE_EXTERNAL_DNS()); + +// With custom prefix +D("extdns-custom.com", "none", IGNORE_EXTERNAL_DNS("extdns-")); + +// Combined with other records +D("extdns-combined.com", "none", + IGNORE_EXTERNAL_DNS(), + A("www", "1.2.3.4"), + CNAME("api", "www") +); diff --git a/pkg/js/parse_tests/058-ignore-external-dns.json b/pkg/js/parse_tests/058-ignore-external-dns.json new file mode 100644 index 000000000..7bd32d3a0 --- /dev/null +++ b/pkg/js/parse_tests/058-ignore-external-dns.json @@ -0,0 +1,52 @@ +{ + "registrars": [], + "dns_providers": [], + "domains": [ + { + "name": "extdns-default.com", + "registrar": "none", + "dnsProviders": {}, + "meta": { + "dnscontrol_uniquename": "extdns-default.com" + }, + "records": [], + "ignore_external_dns": true + }, + { + "name": "extdns-custom.com", + "registrar": "none", + "dnsProviders": {}, + "meta": { + "dnscontrol_uniquename": "extdns-custom.com" + }, + "records": [], + "ignore_external_dns": true, + "external_dns_prefix": "extdns-" + }, + { + "name": "extdns-combined.com", + "registrar": "none", + "dnsProviders": {}, + "meta": { + "dnscontrol_uniquename": "extdns-combined.com" + }, + "records": [ + { + "type": "CNAME", + "name": "api", + "ttl": 300, + "filepos": "[line:12:5]", + "target": "www.extdns-combined.com." + }, + { + "type": "A", + "name": "www", + "ttl": 300, + "filepos": "[line:11:5]", + "target": "1.2.3.4" + } + ], + "ignore_external_dns": true + } + ] +} From 00f1bd8e852779d343a226300c7ebbea82a17508 Mon Sep 17 00:00:00 2001 From: Sukka Date: Wed, 3 Dec 2025 22:11:16 +0800 Subject: [PATCH 2/4] DOCS: Update Vercel provider docs (#3877) Made a few mistakes when creating the initial version of the docs back in #3542 Fix typos, adjust a few wordings, descriptions, etc. Co-authored-by: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com> --- documentation/provider/vercel.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/documentation/provider/vercel.md b/documentation/provider/vercel.md index e82d36626..05ff207cb 100644 --- a/documentation/provider/vercel.md +++ b/documentation/provider/vercel.md @@ -19,17 +19,17 @@ Example: **API Token** -You can create a Vercel API Token via [Vercel Account Settngs](https://vercel.com/account/settings/tokens). +You can create a Vercel API Token via [Vercel Account Settings - Tokens](https://vercel.com/account/settings/tokens). **How to grab team ID** Log in to your Vercel account and navigate to `https://vercel.com`. Switch to your desired team with the Vercel team switcher if needed. -![Example permissions configuration](../assets/providers/vercel/vercel-account-switcher.png) +![Vercel Team Switcher](../assets/providers/vercel/vercel-account-switcher.png) -Now you can find your team ID in your browser's address bar, copy the path (**without** any leading `/` or trailing `/`) and paste it into your `creds.json` file. +Now you can find your team ID in your browser's address bar, copy the path (**without** any leading `/` or trailing `/`) and paste it into your `creds.json` file's `team_id` field. -![Example permissions configuration](../assets/providers/vercel/vercel-team-id-slug.png) +![Vercel Team ID Slug in Browser Address Bar](../assets/providers/vercel/vercel-team-id-slug.png) If you are familiar with the Vercel API, you can also grab your team ID via Vercel's [Teams - List all teams API](https://vercel.com/docs/rest-api/reference/endpoints/teams/list-all-teams). In response's `id` field you will able to see a string starts with `team_`, and in response's `slug` field you will able to see a string consists of a slugified version of your team name. Both `id` and `slug` can be used as `team_id` for your `creds.json`. @@ -50,7 +50,7 @@ In this case, you should use an empty string as the team ID: ``` {% endcode %} -It is also possible to manually migrate your domains from your Vercel account to your personal team via the link mentioned above. Whether you choose to migrate your domains or not is up to you, this provider supports both cases. +It is possible to manually migrate your domains from your Vercel account to your personal team via the link mentioned above. Whether you choose to migrate your domains or not is up to you, this provider supports both scenarios. ## Usage @@ -82,11 +82,11 @@ You can add your own records and Vercel will prefer your created records over th As of November 2025, the known system-managed records are: - `CAA 0 issue "letsencrypt.org"` - - Vercel uses Let's Encrypt to issue certificates for your project deployed on Vercel, thus Vercel automatically creates a CAA record to ensure Let's Encrypt can issue certificates, but you can always add your own CAA records. + - Vercel uses Let's Encrypt to issue certificates for your project deployed on Vercel, thus Vercel automatically creates a CAA record to ensure Let's Encrypt can issue certificates, but you can always add your own CAA records (you can even add your own `letsencrypt.org` CAA record and Vercel will dedupe that). - `CNAME cname.vercel-dns.com.` - - Vercel uses a CNAME record to point your deployed project to their infrastructure, but you can always add your own CNAME records (which allows you to put a third-party CDN in front of Vercel's infrastructure). + - Vercel always add a CNAME record to point your deployed project to their infrastructure, but you can always add your own A/AAAA/ALIAS/CNAME records (which allows you to put a third-party CDN in front of Vercel's infrastructure), and Vercel will always prefer your records over their default CNAME. -In Vercel's API, those system-managed records will have their `creator` set to `system`. We use this to identify and ignore system-managed records, to prevent DNSControl from interfering with them. You won't see them in `dnscontrol diff` or `dnscontrol preview`. +In Vercel's API, those system-managed records will have their `creator` set to `system`. We use this to identify and ignore system-managed records, to prevent DNSControl from interfering with them. You won't see them in `dnscontrol preview` or `dnscontrol get-zone`. ### Comment @@ -99,7 +99,7 @@ In the future, we might use the comment field to store additional metadata for o As of November 2025, Vercel has a bug that does not accept CAA records with any extra fields that are not `cansigncansignhttpexchanges`: ``` -# OK +# OK CAA 0 issue "letsencrypt.org" CAA 0 issuewild "letsencrypt.org" CAA 0 issue "digicert.com; cansignhttpexchanges=yes" @@ -135,10 +135,12 @@ We will burst through half of the quota, and then it spreads the requests evenly If you are mass migrating your DNS records from another provider to Vercel, we recommended to upload a BIND zone file via [Vercel's DNS Dashboard](https://vercel.com/dashboard/domains). You can use DNSControl to manage your DNS records afterwards. +> This provider does not utilize that feature, as "upload a BIND zone file" is not documented and is not part of Vercel's public API. + ### Change Record Type Vercel does not allow the record type to be changed after creation. If you try to update a record with a different type (e.g. changing `A` to `CNAME/ALIAS`), we will delete the old record and create a new one. This will count as two separate requests, which may exceed the rate limit. Also be careful about the downtime caused by the deletion and creation of records. ### Minimum TTL -Vercel enforces a minimum TTL of 60 seconds (1 minute) for all records. We will always override the TTL to 60 seconds if you try to set a lower TTL. +Vercel enforces a minimum TTL of 60 seconds (1 minute) for all records. We will always silently override the TTL to 60 seconds if you try to set a lower TTL. From e87f03a8a38310359f8d990802c37f940bfb2d34 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:53:02 -0500 Subject: [PATCH 3/4] CHORE: fmt (#3882) --- commands/types/dnscontrol.d.ts | 7 ++++ models/domain.go | 4 +- package-lock.json | 11 ++--- package.json | 2 +- pkg/diff2/handsoff_test.go | 14 +++---- pkg/js/helpers.js | 12 +++--- .../parse_tests/058-ignore-external-dns.json | 40 +++++++++---------- 7 files changed, 49 insertions(+), 41 deletions(-) diff --git a/commands/types/dnscontrol.d.ts b/commands/types/dnscontrol.d.ts index ae3ab5ac1..65d694af9 100644 --- a/commands/types/dnscontrol.d.ts +++ b/commands/types/dnscontrol.d.ts @@ -1773,6 +1773,13 @@ declare function IGNORE(labelSpec: string, typeSpec?: string, targetSpec?: strin * * ## Caveats * + * ### One per domain + * + * Only one `IGNORE_EXTERNAL_DNS()` should be used per domain. If you call it multiple + * times, the last prefix wins. If you have multiple external-dns instances with + * different prefixes managing the same zone, use `IGNORE()` patterns for additional + * prefixes. + * * ### TXT Registry Format * * This feature relies on external-dns's [TXT registry](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/registry/txt.md), diff --git a/models/domain.go b/models/domain.go index fa8bd9ba6..e45b1fbb8 100644 --- a/models/domain.go +++ b/models/domain.go @@ -40,8 +40,8 @@ type DomainConfig struct { Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` // IGNORE() UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` // DISABLE_IGNORE_SAFETY_CHECK - IgnoreExternalDNS bool `json:"ignore_external_dns,omitempty"` // IGNORE_EXTERNAL_DNS - ExternalDNSPrefix string `json:"external_dns_prefix,omitempty"` // IGNORE_EXTERNAL_DNS prefix + IgnoreExternalDNS bool `json:"ignore_external_dns,omitempty"` // IGNORE_EXTERNAL_DNS + ExternalDNSPrefix string `json:"external_dns_prefix,omitempty"` // IGNORE_EXTERNAL_DNS prefix AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off" // DNSSEC bool `json:"dnssec,omitempty"` diff --git a/package-lock.json b/package-lock.json index 4fa5d2c5a..8a58e55a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@umbrelladocs/linkspector": "^0.3.13", - "prettier": "^3.6.2" + "prettier": "^3.7.4" } }, "node_modules/@babel/code-frame": { @@ -715,7 +715,8 @@ "version": "0.0.1367902", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -2221,9 +2222,9 @@ "license": "ISC" }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index ea57754e9..251b962a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { "@umbrelladocs/linkspector": "^0.3.13", - "prettier": "^3.6.2" + "prettier": "^3.7.4" } } diff --git a/pkg/diff2/handsoff_test.go b/pkg/diff2/handsoff_test.go index ebf2b55c4..b0345d1f7 100644 --- a/pkg/diff2/handsoff_test.go +++ b/pkg/diff2/handsoff_test.go @@ -266,8 +266,8 @@ func Test_ignore_external_dns(t *testing.T) { domain, existing, desired, - nil, // absences - nil, // unmanagedConfigs + nil, // absences + nil, // unmanagedConfigs false, // unmanagedSafely false, // noPurge true, // ignoreExternalDNS @@ -352,11 +352,11 @@ func Test_ignore_external_dns_custom_prefix(t *testing.T) { domain, existing, desired, - nil, // absences - nil, // unmanagedConfigs - false, // unmanagedSafely - false, // noPurge - true, // ignoreExternalDNS + nil, // absences + nil, // unmanagedConfigs + false, // unmanagedSafely + false, // noPurge + true, // ignoreExternalDNS "extdns-", // externalDNSPrefix ) if err != nil { diff --git a/pkg/js/helpers.js b/pkg/js/helpers.js index 46260624f..f46f1e20d 100644 --- a/pkg/js/helpers.js +++ b/pkg/js/helpers.js @@ -866,15 +866,15 @@ function locStringBuilder(record, args) { (args.alt < -100000 ? -100000 : args.alt > 42849672.95 - ? 42849672.95 - : args.alt.toString()) + 'm'; + ? 42849672.95 + : args.alt.toString()) + 'm'; precisionbuffer += ' ' + (args.siz > 90000000 ? 90000000 : args.siz < 0 - ? 0 - : args.siz.toString()) + + ? 0 + : args.siz.toString()) + 'm'; precisionbuffer += ' ' + @@ -914,8 +914,8 @@ function locDMSBuilder(record, args) { record.localtitude > 4294967295 ? 4294967295 : record.localtitude < 0 - ? 0 - : record.localtitude; + ? 0 + : record.localtitude; // Size record.locsize = getENotationInt(args.siz); // Horizontal Precision diff --git a/pkg/js/parse_tests/058-ignore-external-dns.json b/pkg/js/parse_tests/058-ignore-external-dns.json index 7bd32d3a0..0a4f1306d 100644 --- a/pkg/js/parse_tests/058-ignore-external-dns.json +++ b/pkg/js/parse_tests/058-ignore-external-dns.json @@ -1,52 +1,52 @@ { - "registrars": [], "dns_providers": [], "domains": [ { - "name": "extdns-default.com", - "registrar": "none", "dnsProviders": {}, + "ignore_external_dns": true, "meta": { "dnscontrol_uniquename": "extdns-default.com" }, + "name": "extdns-default.com", "records": [], - "ignore_external_dns": true + "registrar": "none" }, { - "name": "extdns-custom.com", - "registrar": "none", "dnsProviders": {}, + "external_dns_prefix": "extdns-", + "ignore_external_dns": true, "meta": { "dnscontrol_uniquename": "extdns-custom.com" }, + "name": "extdns-custom.com", "records": [], - "ignore_external_dns": true, - "external_dns_prefix": "extdns-" + "registrar": "none" }, { - "name": "extdns-combined.com", - "registrar": "none", "dnsProviders": {}, + "ignore_external_dns": true, "meta": { "dnscontrol_uniquename": "extdns-combined.com" }, + "name": "extdns-combined.com", "records": [ { - "type": "CNAME", - "name": "api", - "ttl": 300, "filepos": "[line:12:5]", - "target": "www.extdns-combined.com." + "name": "api", + "target": "www.extdns-combined.com.", + "ttl": 300, + "type": "CNAME" }, { - "type": "A", - "name": "www", - "ttl": 300, "filepos": "[line:11:5]", - "target": "1.2.3.4" + "name": "www", + "target": "1.2.3.4", + "ttl": 300, + "type": "A" } ], - "ignore_external_dns": true + "registrar": "none" } - ] + ], + "registrars": [] } From c11a5239827638754ec23c3014aeaf79337e5a7a Mon Sep 17 00:00:00 2001 From: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:31:59 -0500 Subject: [PATCH 4/4] FEATURE: Fixing IDN support for domains (#3879) # Issue The previous fix had backwards compatibility issues and treated uppercase Unicode incorrectly. # Resolution * Don't call strings.ToUpper() on Unicode strings. Only call it on the output of ToASCII. * Fix BIND's "filenameformat" to be more compatible (only breaks if you had uppercase unicode in a domain name... which you probably didn't) * Change IDN to ASCII in most places (Thanks for the suggestion, @KaiSchwarz-cnic!) * Update BIND documentation --- documentation/provider/bind.md | 92 +++++++++++++-------- integrationTest/integration_test.go | 11 +-- models/domain.go | 6 +- pkg/domaintags/domaintags.go | 29 ++++--- pkg/domaintags/domaintags_test.go | 62 ++++++++++---- pkg/domaintags/permitlist.go | 14 ++-- providers/bind/bindProvider.go | 6 +- providers/bind/fnames.go | 57 ++++++++----- providers/bind/fnames_test.go | 121 ++++++++++++++++++++-------- 9 files changed, 266 insertions(+), 132 deletions(-) diff --git a/documentation/provider/bind.md b/documentation/provider/bind.md index 5136e0795..54159098d 100644 --- a/documentation/provider/bind.md +++ b/documentation/provider/bind.md @@ -22,13 +22,18 @@ Example: { "bind": { "TYPE": "BIND", - "directory": "myzones", - "filenameformat": "%U.zone" + "directory": "myzones" } } ``` {% endcode %} +As of v4.2.0 `dnscontrol push` will create subdirectories along the path to +the filename. This includes both the portion of the path created by the +`directory` setting and the `filenameformat` setting. For security reasons, the +automatic creation of subdirectories is disabled if `dnscontrol` is running as +root. (Running DNSControl as root is not recommended in general.) + ## Meta configuration This provider accepts some optional metadata in the `NewDnsProvider()` call. @@ -85,43 +90,59 @@ DNSControl does not handle special serial number math such as "looping through z # filenameformat The `filenameformat` parameter specifies the file name to be used when -writing the zone file. The default (`%U.zone`) is acceptable in most cases: the +writing the zone file. The default (`%c.zone`) is acceptable in most cases: the file name is the name as specified in the `D()` function plus ".zone". The filenameformat is a string with a few printf-like `%` verbs: - * The domain name without tag (the `example.com` part of `example.com!tag`): - * `%D` as specified in `D()` (no IDN conversion, but downcased) - * `%I` converted to IDN/Punycode (`xn--...`) and downcased. - * `%N` converted to Unicode (downcased first) - * `%T` the split horizon tag, or "" (the `tag` part of `example.com!tag`) - * `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable but is usually `!`. - * `%U` short for "%I%?!%T". This is the universal, canonical, name for the domain used for comparisons within DNSControl. This is best for filenames which is why it is used in the default. - * `%%` `%` - * ordinary characters (not `%`) are copied unchanged to the output stream - * FYI: format strings must not end with an incomplete `%` or `%?` +| Verb | Description | `EXAMple.com` | `EXAMple.com!MyTag` | `рф.com!myTag` | +| ------- | ------------------------------------------------- | ------------- | ------------------- | -------------------- | +| `%T` | the tag | "" (null) | `myTag` | `myTag` | +| `%c` | canonical name, globally unique and comparable | `example.com` | `example.com!myTag` | `xn--p1ai.com!myTag` | +| `%a` | ASCII domain (Punycode, downcased) | `example.com` | `example.com` | `xn--p1ai.com` | +| `%u` | Unicode domain (non-Unicode parts downcased) | `example.com` | `example.com` | `рф.com` | +| `%r` | Raw (unmodified) Domain from `D()` (risky!) | `EXAMple.com` | `EXAMple.com` | `рф.com` | +| `%f` | like `%c` but better for filenames (`%a%?_%T`) | `example.com` | `example.com_myTag` | `xn--p1ai.com_myTag` | +| `%F` | like `%f` but reversed order (`%T%?_%a`) | `example.com` | `myTag_example.com` | `myTag_xn--p1ai.com` | +| `%?x` | returns `x` if tag exists, otherwise "" | "" (null) | `x` | `x` | +| `%%` | a literal percent sign | `%` | `%` | `%` | +| `a-Z./` | other printable characters are copied exactly | `a-Z./` | `a-Z./` | `a-Z./` | +| `%U` | (deprecated, use `%c`) Same as `%D%?!%T` (risky!) | `example.com` | `example.com!myTag` | `рф.com!myTag` | +| `%D` | (deprecated, use `%r`) mangles Unicode (risky!) | `example.com` | `example.com` | `рф.com` | -Typical values: +* `%?x` is typically used to generate an optional `!` or `_` if there is a tag. +* `%r` is considered "risky" because it can produce a domain name that is not + canonical. For example, if you use `D("FOO.com")` and later change it to `D("foo.com")`, your file names will change. +* Format strings must not end with an incomplete `%` or `%?` +* Generating a filename without a tag is risky. For example, if the same + `dnsconfig.js` has `D("example.com!inside", DSP_BIND)` and + `D("example.com!outside", DSP_BIND)`, both will use the same filename. + DNSControl will write both zone files to the same file, flapping between the + two. No error or warning will be output. - * `%U.zone` (The default) - * `example.com.zone` or `example.com!tag.zone` - * `%T%?_%I.zone` (optional tag and `_` + domain + `.zone`) - * `tag_example.com.zone` or `example.com.zone` - * `db_%T%?_%D` - * `db_inside_example.com` or `db_example.com` +Useful examples: -{% hint style="warning" %} -**Warning** DNSControl will not warn you if two zones generate the same -filename. Instead, each will write to the same place. The content would end up -flapping back and forth between the two. The best way to prevent this is to -always include the tag (`%T`) or use `%U` which includes the tag. -{% endhint %} +| Verb | Description | `EXAMple.com` | `EXAMple.com!MyTag` | `рф.com!myTag` | +| ------------ | ----------------------------------- | ------------------ | ------------------------ | ------------------------- | +| `%c.zone` | Default format (v4.28 and later) | `example.com.zone` | `example.com!myTag.zone` | `xn--p1ai.com!myTag.zone` | +| `%U.zone` | Default format (pre-v4.28) (risky!) | `example.com.zone` | `example.com!myTag.zone` | `рф.com!myTag.zone` | +| `db_%f` | Recommended in a popular DNS book | `db_example.com` | `db_example.com_myTag` | `db_xn--p1ai.com_myTag` | +| `db_%a%?_%T` | same as above but using `%?_` | `db_example.com` | `db_example.com_myTag` | `db_xn--p1ai.com_myTag` | -(new in v4.2.0) `dnscontrol push` will create subdirectories along the path to -the filename. This includes both the portion of the path created by the -`directory` setting and the `filenameformat` setting. The automatic creation of -subdirectories is disabled if `dnscontrol` is running as root for security -reasons. +Compatibility notes: + +* `%D` should not be used. It downcases the string in a way that is probably + incompatible with Unicode characters. It is retained for compatibility with + pre-v4.28 releases. If your domain has capital Unicode characters, backwards + compatibility is not guaranteed. Use `%r` instead. +* `%U` relies on `%D` which is deprecated. Use `%c` instead. +* As of v4.28 the default format string changed from `%U.zone` to `%c.zone`. This + should only matter if your `D()` statements included non-ASCII (Unicode) + runes that were capitalized. +* If you are using pre-v4.28 releases the above table is slightly misleading + because uppercase ASCII letters do not always work. If you are using + pre-v4.28 releases, assume the above table lists `example.com` instead + of `EXAMpl.com`. # FYI: get-zones @@ -132,7 +153,8 @@ any files named `*.zone` and assumes they are zone files. dnscontrol get-zones --format=nameonly - BIND all ``` -If `filenameformat` is defined, `dnscontrol` makes a guess at which -filenames are zones but doesn't try to hard to get it right, which is -mathematically impossible in some cases. Feel free to file an issue if -your format string doesn't work. I love a challenge! +If `filenameformat` is defined, `dnscontrol` makes a guess at which filenames +are zones by reversing the logic of the format string. It doesn't try very hard +to get this right, as getting it right in all situations is mathematically +impossible. Feel free to file an issue if find a situation where it doesn't +work. I love a challenge! diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index b45cb07c6..6492aa357 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -2033,11 +2033,6 @@ func makeTests() []*TestGroup { ), ), - // This MUST be the last test. - testgroup("final", - tc("final", txt("final", `TestDNSProviders was successful!`)), - ), - testgroup("SMIMEA", requires(providers.CanUseSMIMEA), tc("SMIMEA record", smimea("_443._tcp", 3, 1, 1, sha256hash)), @@ -2060,6 +2055,12 @@ func makeTests() []*TestGroup { // every quarter. There may be library updates, API changes, // etc. + // This SHOULD be the last test. We do this so that we always + // leave zones with a single TXT record exclaming our success. + // Nothing depends on this record existing or should depend on it. + testgroup("final", + tc("final", txt("final", `TestDNSProviders was successful!`)), + ), } return tests diff --git a/models/domain.go b/models/domain.go index e45b1fbb8..8988da469 100644 --- a/models/domain.go +++ b/models/domain.go @@ -13,7 +13,7 @@ const ( DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName DomainNameRaw = "dnscontrol_nameraw" // A copy of DomainConfig.NameRaw - DomainNameIDN = "dnscontrol_nameidn" // A copy of DomainConfig.NameIDN + DomainNameASCII = "dnscontrol_nameascii" // A copy of DomainConfig.NameASCII DomainNameUnicode = "dnscontrol_nameunicode" // A copy of DomainConfig.NameUnicode ) @@ -74,7 +74,7 @@ func (dc *DomainConfig) PostProcess() { // Turn the user-supplied name into the fixed forms. ff := domaintags.MakeDomainFixForms(dc.Name) - dc.Tag, dc.NameRaw, dc.Name, dc.NameUnicode, dc.UniqueName = ff.Tag, ff.NameRaw, ff.NameIDN, ff.NameUnicode, ff.UniqueName + dc.Tag, dc.NameRaw, dc.Name, dc.NameUnicode, dc.UniqueName = ff.Tag, ff.NameRaw, ff.NameASCII, ff.NameUnicode, ff.UniqueName // Store the FixForms is Metadata so we don't have to change the signature of every function that might need them. // This is a bit ugly but avoids a huge refactor. Please avoid using these to make the future refactor easier. @@ -82,7 +82,7 @@ func (dc *DomainConfig) PostProcess() { dc.Metadata[DomainTag] = dc.Tag } //dc.Metadata[DomainNameRaw] = dc.NameRaw - //dc.Metadata[DomainNameIDN] = dc.Name + //dc.Metadata[DomainNameASCII] = dc.Name //dc.Metadata[DomainNameUnicode] = dc.NameUnicode dc.Metadata[DomainUniqueName] = dc.UniqueName } diff --git a/pkg/domaintags/domaintags.go b/pkg/domaintags/domaintags.go index adf07d810..1d933bf9e 100644 --- a/pkg/domaintags/domaintags.go +++ b/pkg/domaintags/domaintags.go @@ -9,7 +9,7 @@ import ( // DomainFixedForms stores the various fixed forms of a domain name and tag. type DomainFixedForms struct { NameRaw string // "originalinput.com" (name as input by the user, lowercased (no tag)) - NameIDN string // "punycode.com" + NameASCII string // "punycode.com" NameUnicode string // "unicode.com" (converted to downcase BEFORE unicode conversion) UniqueName string // "punycode.com!tag" @@ -25,7 +25,7 @@ type DomainFixedForms struct { // * .UniqueName: "example.com!tag" unique across the entire config. func MakeDomainFixForms(n string) DomainFixedForms { var err error - var tag, nameRaw, nameIDN, nameUnicode, uniqueName string + var tag, nameRaw, nameASCII, nameUnicode, uniqueName string var hasBang bool // Split tag from name. @@ -38,42 +38,45 @@ func MakeDomainFixForms(n string) DomainFixedForms { hasBang = false } - nameRaw = strings.ToLower(p[0]) + nameRaw = p[0] if strings.HasPrefix(n, nameRaw) { // Avoid pointless duplication. nameRaw = n[0:len(nameRaw)] } - nameIDN, err = idna.ToASCII(nameRaw) + nameASCII, err = idna.ToASCII(nameRaw) if err != nil { - nameIDN = nameRaw // Fallback to raw name on error. + nameASCII = nameRaw // Fallback to raw name on error. } else { + nameASCII = strings.ToLower(nameASCII) // Avoid pointless duplication. - if nameIDN == nameRaw { - nameIDN = nameRaw + if strings.HasPrefix(n, nameASCII) { + // Avoid pointless duplication. + nameASCII = n[0:len(nameASCII)] } } - nameUnicode, err = idna.ToUnicode(nameRaw) + nameUnicode, err = idna.ToUnicode(nameASCII) // We use nameASCII since it is already lowercased. if err != nil { nameUnicode = nameRaw // Fallback to raw name on error. } else { // Avoid pointless duplication. - if nameUnicode == nameRaw { - nameUnicode = nameRaw + if strings.HasPrefix(n, nameUnicode) { + // Avoid pointless duplication. + nameUnicode = n[0:len(nameUnicode)] } } if hasBang { - uniqueName = nameIDN + "!" + tag + uniqueName = nameASCII + "!" + tag } else { - uniqueName = nameIDN + uniqueName = nameASCII } return DomainFixedForms{ Tag: tag, NameRaw: nameRaw, - NameIDN: nameIDN, + NameASCII: nameASCII, NameUnicode: nameUnicode, UniqueName: uniqueName, HasBang: hasBang, diff --git a/pkg/domaintags/domaintags_test.go b/pkg/domaintags/domaintags_test.go index f6c010c33..63f1bb04d 100644 --- a/pkg/domaintags/domaintags_test.go +++ b/pkg/domaintags/domaintags_test.go @@ -10,7 +10,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input string wantTag string wantNameRaw string - wantNameIDN string + wantNameASCII string wantNameUnicode string wantUniqueName string wantHasBang bool @@ -20,7 +20,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "example.com", wantTag: "", wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com", wantHasBang: false, @@ -30,7 +30,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "example.com!mytag", wantTag: "mytag", wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com!mytag", wantHasBang: true, @@ -40,7 +40,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "example.com!", wantTag: "", wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com!", wantHasBang: true, @@ -50,7 +50,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "उदाहरण.com", wantTag: "", wantNameRaw: "उदाहरण.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com", wantHasBang: false, @@ -60,7 +60,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "उदाहरण.com!mytag", wantTag: "mytag", wantNameRaw: "उदाहरण.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", wantHasBang: true, @@ -70,17 +70,29 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "xn--p1b6ci4b4b3a.com", wantTag: "", wantNameRaw: "xn--p1b6ci4b4b3a.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com", wantHasBang: false, }, + { + // Unicode chars should be left alone (as far as case folding goes) + // Here are some Armenian characters https://tools.lgm.cl/lettercase.html + name: "mixed case unicode", + input: "fooԷէԸըԹ.com!myTag", + wantTag: "myTag", + wantNameRaw: "fooԷէԸըԹ.com", + wantNameASCII: "xn--foo-b7dfg43aja.com", + wantNameUnicode: "fooԷէԸըԹ.com", + wantUniqueName: "xn--foo-b7dfg43aja.com!myTag", + wantHasBang: true, + }, { name: "punycode domain with tag", input: "xn--p1b6ci4b4b3a.com!mytag", wantTag: "mytag", wantNameRaw: "xn--p1b6ci4b4b3a.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", wantHasBang: true, @@ -89,8 +101,8 @@ func Test_MakeDomainFixForms(t *testing.T) { name: "mixed case domain", input: "Example.COM", wantTag: "", - wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameRaw: "Example.COM", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com", wantHasBang: false, @@ -99,12 +111,34 @@ func Test_MakeDomainFixForms(t *testing.T) { name: "mixed case domain with tag", input: "Example.COM!MyTag", wantTag: "MyTag", - wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameRaw: "Example.COM", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com!MyTag", wantHasBang: true, }, + // This is used in the documentation for the BIND provider, thus we test + // it to make sure we got it right. + { + name: "BIND example 1", + input: "рф.com!myTag", + wantTag: "myTag", + wantNameRaw: "рф.com", + wantNameASCII: "xn--p1ai.com", + wantNameUnicode: "рф.com", + wantUniqueName: "xn--p1ai.com!myTag", + wantHasBang: true, + }, + { + name: "BIND example 2", + input: "рф.com", + wantTag: "", + wantNameRaw: "рф.com", + wantNameASCII: "xn--p1ai.com", + wantNameUnicode: "рф.com", + wantUniqueName: "xn--p1ai.com", + wantHasBang: false, + }, } for _, tt := range tests { @@ -116,8 +150,8 @@ func Test_MakeDomainFixForms(t *testing.T) { if got.NameRaw != tt.wantNameRaw { t.Errorf("MakeDomainFixForms() gotNameRaw = %v, want %v", got.NameRaw, tt.wantNameRaw) } - if got.NameIDN != tt.wantNameIDN { - t.Errorf("MakeDomainFixForms() gotNameIDN = %v, want %v", got.NameIDN, tt.wantNameIDN) + if got.NameASCII != tt.wantNameASCII { + t.Errorf("MakeDomainFixForms() gotNameASCII = %v, want %v", got.NameASCII, tt.wantNameASCII) } if got.NameUnicode != tt.wantNameUnicode { t.Errorf("MakeDomainFixForms() gotNameUnicode = %v, want %v", got.NameUnicode, tt.wantNameUnicode) diff --git a/pkg/domaintags/permitlist.go b/pkg/domaintags/permitlist.go index b9e8e904b..220a2cad3 100644 --- a/pkg/domaintags/permitlist.go +++ b/pkg/domaintags/permitlist.go @@ -24,8 +24,8 @@ func CompilePermitList(s string) PermitList { continue } ff := MakeDomainFixForms(l) - if ff.HasBang && ff.NameIDN == "" { // Treat empty name as wildcard. - ff.NameIDN = "*" + if ff.HasBang && ff.NameASCII == "" { // Treat empty name as wildcard. + ff.NameASCII = "*" } sl.items = append(sl.items, ff) } @@ -65,24 +65,24 @@ func (pl *PermitList) Permitted(domToCheck string) bool { // Now that we know the tag matches, we can focus on the name. // `*!tag` or `*` matches everything. - if filterItem.NameIDN == "*" { + if filterItem.NameASCII == "*" { return true } // If the name starts with "*." then match the suffix. - if strings.HasPrefix(filterItem.NameIDN, "*.") { + if strings.HasPrefix(filterItem.NameASCII, "*.") { // example.com matches *.example.com - if domToCheckFF.NameIDN == filterItem.NameIDN[2:] || domToCheckFF.NameUnicode == filterItem.NameUnicode[2:] { + if domToCheckFF.NameASCII == filterItem.NameASCII[2:] || domToCheckFF.NameUnicode == filterItem.NameUnicode[2:] { return true } // foo.example.com matches *.example.com - if strings.HasSuffix(domToCheckFF.NameIDN, filterItem.NameIDN[1:]) || strings.HasSuffix(domToCheckFF.NameUnicode, filterItem.NameUnicode[1:]) { + if strings.HasSuffix(domToCheckFF.NameASCII, filterItem.NameASCII[1:]) || strings.HasSuffix(domToCheckFF.NameUnicode, filterItem.NameUnicode[1:]) { return true } } // No wildcards? Exact match. - if filterItem.NameIDN == domToCheckFF.NameIDN || filterItem.NameUnicode == domToCheckFF.NameUnicode { + if filterItem.NameASCII == domToCheckFF.NameASCII || filterItem.NameUnicode == domToCheckFF.NameUnicode { return true } } diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index 525e8c74f..a71cf25d6 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -69,7 +69,7 @@ func initBind(config map[string]string, providermeta json.RawMessage) (providers api.directory = "zones" } if api.filenameformat == "" { - api.filenameformat = "%U.zone" + api.filenameformat = "%c.zone" } if len(providermeta) != 0 { err := json.Unmarshal(providermeta, api) @@ -171,7 +171,7 @@ func (c *bindProvider) GetZoneRecords(domain string, meta map[string]string) (mo ff := domaintags.DomainFixedForms{ Tag: meta[models.DomainTag], NameRaw: meta[models.DomainNameRaw], - NameIDN: domain, + NameASCII: domain, NameUnicode: meta[models.DomainNameUnicode], UniqueName: meta[models.DomainUniqueName], } @@ -282,7 +282,7 @@ func (c *bindProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, foundR domaintags.DomainFixedForms{ Tag: dc.Tag, NameRaw: dc.NameRaw, - NameIDN: dc.Name, + NameASCII: dc.Name, NameUnicode: dc.NameUnicode, UniqueName: dc.UniqueName, }, diff --git a/providers/bind/fnames.go b/providers/bind/fnames.go index db7b22514..b333e390d 100644 --- a/providers/bind/fnames.go +++ b/providers/bind/fnames.go @@ -13,11 +13,6 @@ import ( // makeFileName uses format to generate a zone's filename. See the func makeFileName(format string, ff domaintags.DomainFixedForms) string { //fmt.Printf("DEBUG: makeFileName(%q, %+v)\n", format, ff) - nameRaw := ff.NameRaw - nameIDN := ff.NameIDN - nameUnicode := ff.NameUnicode - uniquename := ff.UniqueName - tag := ff.Tag if format == "" { panic("BUG: makeFileName called with null format") } @@ -40,16 +35,31 @@ func makeFileName(format string, ff domaintags.DomainFixedForms) string { pos++ tok = tokens[pos] switch tok { - case "D": - b.WriteString(nameRaw) - case "T": - b.WriteString(tag) - case "U": - b.WriteString(uniquename) - case "I": - b.WriteString(nameIDN) - case "N": - b.WriteString(nameUnicode) + + // v4.28 names + case "r": // NameRaw "originalinput.com" (i for input) + b.WriteString(ff.NameRaw) + case "a": // NameASCII "punycode.com" (a for ascii) + b.WriteString(ff.NameASCII) + case "u": // NameUnicode "unicode.com" (u for unicode) + b.WriteString(ff.NameUnicode) + case "c": // UniqueName "punycode.com!tag" or "punycode.com" if no tag (c for canonical) + b.WriteString(ff.UniqueName) + case "f": // + b.WriteString(ff.NameASCII) + if ff.Tag != "" { + b.WriteString("_") + b.WriteString(ff.Tag) + } + case "F": // + if ff.Tag != "" { + b.WriteString(ff.Tag) + b.WriteString("_") + } + b.WriteString(ff.NameASCII) + case "T": // Tag The tag portion of `example.com!tag` + b.WriteString(ff.Tag) + case "%": b.WriteString("%") case "?": @@ -59,9 +69,20 @@ func makeFileName(format string, ff domaintags.DomainFixedForms) string { } pos++ tok = tokens[pos] - if tag != "" { + if ff.Tag != "" { b.WriteString(tok) } + + // Legacy names kept for compatibility + case "U": // the domain name as specified in `D()` + b.WriteString(strings.ToLower(ff.NameRaw)) + if ff.Tag != "" { + b.WriteString("!") + b.WriteString(ff.Tag) + } + case "D": // domain (without tag) as specified in D() (no IDN conversion, but downcased) + b.WriteString(strings.ToLower(ff.NameRaw)) + default: fmt.Fprintf(&b, "%%(unknown %%verb %%%s)", tok) } @@ -156,14 +177,14 @@ func makeExtractor(format string) (string, error) { pos++ tok = tokens[pos] switch tok { - case "D": + case "D", "a", "u", "r": b.WriteString(`(.*)`) case "T": if pass == 0 { // On the second pass, nothing is generated. b.WriteString(`.*`) } - case "U": + case "U", "c": if pass == 0 { b.WriteString(`(.*)!.+`) } else { diff --git a/providers/bind/fnames_test.go b/providers/bind/fnames_test.go index 66a0bcb8a..b37d2a58c 100644 --- a/providers/bind/fnames_test.go +++ b/providers/bind/fnames_test.go @@ -8,29 +8,28 @@ import ( ) func Test_makeFileName(t *testing.T) { - ff := domaintags.DomainFixedForms{ - NameRaw: "raw", - NameIDN: "idn", - NameUnicode: "unicode", - UniqueName: "unique!taga", - Tag: "tagb", - } - tagless := domaintags.DomainFixedForms{ - NameRaw: "raw", - NameIDN: "idn", - NameUnicode: "unicode", - UniqueName: "unique", - Tag: "", - } fmtDefault := "%U.zone" fmtBasic := "%U - %T - %D" - fmtBk1 := "db_%U" // Something I've seen in books on DNS - fmtBk2 := "db_%T_%D" // Something I've seen in books on DNS fmtFancy := "%T%?_%D.zone" // Include the tag_ only if there is a tag fmtErrorPct := "literal%" fmtErrorOpt := "literal%?" fmtErrorUnk := "literal%o" // Unknown % verb + ff := domaintags.DomainFixedForms{ + NameRaw: "domy", + NameASCII: "idn", + NameUnicode: "uni", + UniqueName: "unique!taga", + Tag: "tagy", + } + tagless := domaintags.DomainFixedForms{ + NameRaw: "domy", + NameASCII: "idn", + NameUnicode: "uni", + UniqueName: "unique", + Tag: "", + } + type args struct { format string ff domaintags.DomainFixedForms @@ -40,25 +39,15 @@ func Test_makeFileName(t *testing.T) { args args want string }{ + // Test corner cases and common cases. {"literal", args{"literal", ff}, "literal"}, - {"middle", args{"mid%Dle", ff}, "midrawle"}, - {"D", args{"%D", ff}, "raw"}, - {"I", args{"%I", ff}, "idn"}, - {"N", args{"%N", ff}, "unicode"}, - {"T", args{"%T", ff}, "tagb"}, - {"x1", args{"XX%?xYY", ff}, "XXxYY"}, - {"x2", args{"AA%?xBB", tagless}, "AABB"}, - {"U", args{"%U", ff}, "unique!taga"}, - {"percent", args{"%%", ff}, "%"}, - // - {"default", args{fmtDefault, ff}, "unique!taga.zone"}, - {"basic", args{fmtBasic, ff}, "unique!taga - tagb - raw"}, - {"front", args{"%Daaa", ff}, "rawaaa"}, - {"tail", args{"bbb%D", ff}, "bbbraw"}, - {"bk1", args{fmtBk1, ff}, "db_unique!taga"}, - {"bk2", args{fmtBk2, ff}, "db_tagb_raw"}, - {"fanWI", args{fmtFancy, ff}, "tagb_raw.zone"}, - {"fanWO", args{fmtFancy, tagless}, "raw.zone"}, + {"basic", args{fmtBasic, ff}, "domy!tagy - tagy - domy"}, + {"solo", args{"%D", ff}, "domy"}, + {"front", args{"%Daaa", ff}, "domyaaa"}, + {"tail", args{"bbb%D", ff}, "bbbdomy"}, + {"def", args{fmtDefault, ff}, "domy!tagy.zone"}, + {"fanWI", args{fmtFancy, ff}, "tagy_domy.zone"}, + {"fanWO", args{fmtFancy, tagless}, "domy.zone"}, {"errP", args{fmtErrorPct, ff}, "literal%(format may not end in %)"}, {"errQ", args{fmtErrorOpt, ff}, "literal%(format may not end in %?)"}, {"errU", args{fmtErrorUnk, ff}, "literal%(unknown %verb %o)"}, @@ -73,6 +62,66 @@ func Test_makeFileName(t *testing.T) { } } +func Test_makeFileName_2(t *testing.T) { + ff1 := domaintags.MakeDomainFixForms(`EXAMple.com`) + ff2 := domaintags.MakeDomainFixForms(`EXAMple.com!myTag`) + ff3 := domaintags.MakeDomainFixForms(`рф.com!myTag`) + + tests := []struct { + name string + format string + want1 string + want2 string + want3 string + descr string // Not used in test, just for documentation generation + }{ + // NOTE: "Domain" in these descriptions means the domain name without any split horizon tag. Technically the "Zone". + {`T`, `%T`, ``, `myTag`, `myTag`, `the tag`}, + {`c`, `%c`, `example.com`, `example.com!myTag`, `xn--p1ai.com!myTag`, `canonical name, globally unique and comparable`}, + {`a`, `%a`, `example.com`, `example.com`, `xn--p1ai.com`, `ASCII domain (Punycode, downcased)`}, + {`u`, `%u`, `example.com`, `example.com`, `рф.com`, `Unicode domain (non-Unicode parts downcased)`}, + {`r`, `%r`, `EXAMple.com`, `EXAMple.com`, `рф.com`, "Raw (unmodified) Domain from `D()` (risky!)"}, + {`f`, `%f`, `example.com`, `example.com_myTag`, `xn--p1ai.com_myTag`, "like `%c` but better for filenames (`%a%?_%T`)"}, + {`F`, `%F`, `example.com`, `myTag_example.com`, `myTag_xn--p1ai.com`, "like `%f` but reversed order (`%T%?_%a`)"}, + {`%?x`, `%?x`, ``, `x`, `x`, "returns `x` if tag exists, otherwise \"\""}, + {`%`, `%%`, `%`, `%`, `%`, `a literal percent sign`}, + + // Pre-v4.28 names kept for compatibility (note: pre v4.28 did not permit mixed case domain names, we downcased them here for the tests) + {`U`, `%U`, `example.com`, `example.com!myTag`, `рф.com!myTag`, "(deprecated, use `%c`) Same as `%D%?!%T` (risky!)"}, + {`D`, `%D`, `example.com`, `example.com`, `рф.com`, "(deprecated, use `%r`) mangles Unicode (risky!)"}, + {`%T%?_%D.zone`, `%T%?_%D.zone`, `example.com.zone`, `myTag_example.com.zone`, `myTag_рф.com.zone`, `mentioned in the docs`}, + {`db_%T%?_%D`, `db_%T%?_%D`, `db_example.com`, `db_myTag_example.com`, `db_myTag_рф.com`, `mentioned in the docs`}, + {`db_%D`, `db_%D`, `db_example.com`, `db_example.com`, `db_рф.com`, `mentioned in the docs`}, + + // Examples used in the documentation for the BIND provider + {`%c.zone`, `%c.zone`, "example.com.zone", "example.com!myTag.zone", "xn--p1ai.com!myTag.zone", "Default format (v4.28 and later)"}, + {`%U.zone`, `%U.zone`, `example.com.zone`, `example.com!myTag.zone`, `рф.com!myTag.zone`, "Default format (pre-v4.28) (risky!)"}, + {`db_%f`, `db_%f`, `db_example.com`, `db_example.com_myTag`, `db_xn--p1ai.com_myTag`, "Recommended in a popular DNS book"}, + {`db_a%?_%T`, `db_%a%?_%T`, `db_example.com`, `db_example.com_myTag`, `db_xn--p1ai.com_myTag`, "same as above but using `%?_`"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got1 := makeFileName(tt.format, ff1) + if got1 != tt.want1 { + t.Errorf("makeFileName(%q) = ff1 %q, want %q", tt.format, got1, tt.want1) + } + got2 := makeFileName(tt.format, ff2) + if got2 != tt.want2 { + t.Errorf("makeFileName(%q) = ff2 %q, want %q", tt.format, got2, tt.want2) + } + got3 := makeFileName(tt.format, ff3) + if got3 != tt.want3 { + t.Errorf("makeFileName(%q) = ff3 %q, want %q", tt.format, got3, tt.want3) + } + //Uncomment to regenerate lines used in documentation/provider/bind.md 's table: + // fmt.Print(strings.ReplaceAll(fmt.Sprintf("| `%s` | %s | `%s` | `%s` | `%s` |\n", tt.format, tt.descr, got1, got2, got3), "``", "`\"\"` (null)")) + //Uncomment to regenerate the above test cases: + // fmt.Printf("{`%s`, `%s`, `%s`, `%s`, `%s`, %q},\n", tt.name, tt.format, got1, got2, got3, tt.descr) + }) + } +} + func Test_makeExtractor(t *testing.T) { type args struct { format string @@ -83,6 +132,10 @@ func Test_makeExtractor(t *testing.T) { want string wantErr bool }{ + {"c", args{"%c.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false}, + {"a", args{"%a.zone"}, `(.*)\.zone`, false}, + {"u", args{"%u.zone"}, `(.*)\.zone`, false}, + {"r", args{"%r.zone"}, `(.*)\.zone`, false}, // TODO: Add test cases. {"u", args{"%U.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false}, {"d", args{"%D.zone"}, `(.*)\.zone`, false},