Merge branch 'main' into tlim_idn

This commit is contained in:
Thomas Limoncelli 2025-12-03 10:38:17 -05:00
commit 54542e24e5
No known key found for this signature in database
13 changed files with 1557 additions and 40 deletions

View file

@ -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=<owner-id>,external-dns/resource=<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-<name>` for A records
* - `aaaa-<name>` for AAAA records
* - `cname-<name>` for CNAME records
* - `ns-<name>` for NS records
* - `mx-<name>` for MX records
* - `srv-<name>` for SRV records
* - `txt-<name>` 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=<owner-id>,external-dns/resource=<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, "*", "*")`.
*

View file

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

View file

@ -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=<owner-id>,external-dns/resource=<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-<name>` for A records
- `aaaa-<name>` for AAAA records
- `cname-<name>` for CNAME records
- `ns-<name>` for NS records
- `mx-<name>` for MX records
- `srv-<name>` for SRV records
- `txt-<name>` 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=<owner-id>,external-dns/resource=<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)

View file

@ -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.

View file

@ -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"`

View file

@ -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

230
pkg/diff2/externaldns.go Normal file
View file

@ -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=<owner-id>,external-dns/resource=<resource>"
//
// External-dns TXT record naming conventions:
// - For A records: prefix + original name (e.g., "a-myapp.example.com" for "myapp.example.com")
// - For CNAME records: prefix + original name (e.g., "cname-myapp.example.com")
// - Default prefixes: "a-", "aaaa-", "cname-", "ns-", "mx-"
// - Can also use --txt-prefix or --txt-suffix flags in external-dns
import (
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
)
const (
// externalDNSHeritage is the heritage value that external-dns uses in its TXT records
externalDNSHeritage = "heritage=external-dns"
)
// externalDNSManagedRecord represents a record managed by external-dns
type externalDNSManagedRecord struct {
Label string // The label of the managed record (without domain suffix)
RecordType string // The type of the managed record (A, AAAA, CNAME, etc.)
}
// isExternalDNSTxtRecord checks if a TXT record is an external-dns ownership record.
// It returns true and the managed record info if it is, false otherwise.
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
func isExternalDNSTxtRecord(rec *models.RecordConfig, domain string, customPrefix string) (bool, *externalDNSManagedRecord) {
if rec.Type != "TXT" {
return false, nil
}
// Get the TXT record content
target := rec.GetTargetTXTJoined()
// Check if it contains the external-dns heritage marker
if !strings.Contains(target, externalDNSHeritage) {
return false, nil
}
// This is an external-dns TXT record. Now we need to figure out what record it manages.
// External-dns TXT record naming:
// - New format with record type prefix: "a-myapp.example.com" manages "myapp.example.com" A record
// - Old format without type: "myapp.example.com" (legacy, manages the record with same name)
// - With custom prefix: e.g., "externaldns-a-myapp.example.com"
// - With custom suffix: e.g., "myapp-externaldns.example.com"
label := rec.GetLabel()
managed := parseExternalDNSTxtLabel(label, customPrefix)
return true, managed
}
// parseExternalDNSTxtLabel parses an external-dns TXT record label to extract
// the managed record information.
//
// External-dns uses these prefixes by default (when using %{record_type} in prefix):
// - "a-" for A records
// - "aaaa-" for AAAA records
// - "cname-" for CNAME records
// - "ns-" for NS records
// - "mx-" for MX records
//
// Without %{record_type}, it just uses the prefix directly, and the record type
// is encoded as "a-", "cname-", etc. at the start of the label.
//
// If customPrefix is non-empty, it will be stripped first before looking for
// record type prefixes.
func parseExternalDNSTxtLabel(label string, customPrefix string) *externalDNSManagedRecord {
workingLabel := label
// If a custom prefix is specified, strip it first
if customPrefix != "" {
if strings.HasPrefix(strings.ToLower(workingLabel), strings.ToLower(customPrefix)) {
workingLabel = workingLabel[len(customPrefix):]
} else {
// Custom prefix specified but not found - this might be a legacy record
// Continue with original label
}
}
// Standard prefixes used by external-dns
// Supports both hyphen format (a-www) and period format (a.www)
// Period format is used when --txt-prefix includes %{record_type}.
prefixes := []struct {
prefix string
recordType string
}{
{"aaaa.", "AAAA"}, // Period format - must check before "a."
{"aaaa-", "AAAA"}, // Hyphen format - must check before "a-"
{"a.", "A"}, // Period format
{"a-", "A"}, // Hyphen format
{"cname.", "CNAME"},
{"cname-", "CNAME"},
{"ns.", "NS"},
{"ns-", "NS"},
{"mx.", "MX"},
{"mx-", "MX"},
{"srv.", "SRV"},
{"srv-", "SRV"},
{"txt.", "TXT"},
{"txt-", "TXT"},
}
for _, p := range prefixes {
if strings.HasPrefix(strings.ToLower(workingLabel), p.prefix) {
managedLabel := workingLabel[len(p.prefix):]
// managedLabel is already lowercase from the prefix match
// Handle the case where the managed label is empty (apex domain)
if managedLabel == "" {
managedLabel = "@"
}
return &externalDNSManagedRecord{
Label: managedLabel,
RecordType: p.recordType,
}
}
}
// If custom prefix was specified and stripped, check if the remaining label
// is a record type indicator (for period format apex domains: extdns-a. at apex becomes extdns-a)
if customPrefix != "" && workingLabel != label {
// Check if remaining label is just a record type (apex domain with period format)
// e.g., prefix "extdns-" with label "extdns-a" → workingLabel "a" → apex A record
apexRecordTypes := map[string]string{
"a": "A",
"aaaa": "AAAA",
"cname": "CNAME",
"ns": "NS",
"mx": "MX",
"srv": "SRV",
"txt": "TXT",
}
if recType, ok := apexRecordTypes[strings.ToLower(workingLabel)]; ok {
return &externalDNSManagedRecord{
Label: "@",
RecordType: recType,
}
}
// The prefix was stripped but no record type found
// This means it's a simple prefix like "extdns-" without record type
// We can't determine the record type, so match all types
if workingLabel == "" {
workingLabel = "@"
}
return &externalDNSManagedRecord{
Label: workingLabel,
RecordType: "", // Empty means match any type
}
}
// No recognized prefix - this might be a legacy format or custom prefix
// In legacy format, the TXT record has the same name as the managed record
// We can't determine the record type in this case, so we'll match all types
return &externalDNSManagedRecord{
Label: label,
RecordType: "", // Empty means match any type
}
}
// findExternalDNSManagedRecords scans the existing records for external-dns TXT records
// and builds a map of records that are managed by external-dns.
// Returns a map keyed by "label:type" -> true for managed records
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
func findExternalDNSManagedRecords(existing models.Records, domain string, customPrefix string) map[string]bool {
managed := make(map[string]bool)
// Scan all external-dns TXT records
for _, rec := range existing {
isExtDNS, info := isExternalDNSTxtRecord(rec, domain, customPrefix)
if isExtDNS && info != nil {
// Mark the TXT record itself as managed
txtKey := rec.GetLabel() + ":TXT"
managed[txtKey] = true
// Mark the record that this TXT record manages
if info.RecordType != "" {
// Specific record type
key := info.Label + ":" + info.RecordType
managed[key] = true
} else {
// Legacy format - we need to find matching records
// We'll mark this label as managed for common record types
for _, rtype := range []string{"A", "AAAA", "CNAME", "NS", "MX", "SRV"} {
key := info.Label + ":" + rtype
managed[key] = true
}
}
}
}
return managed
}
// filterExternalDNSRecords takes a list of existing records and returns those
// that should be ignored because they are managed by external-dns.
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
func filterExternalDNSRecords(existing models.Records, domain string, customPrefix string) models.Records {
managedMap := findExternalDNSManagedRecords(existing, domain, customPrefix)
if len(managedMap) == 0 {
return nil
}
var ignored models.Records
for _, rec := range existing {
key := rec.GetLabel() + ":" + rec.Type
if managedMap[key] {
ignored = append(ignored, rec)
}
}
return ignored
}
// GetExternalDNSIgnoredRecords returns the records that should be ignored
// because they are managed by external-dns. This is called from handsoff()
// when IgnoreExternalDNS is enabled for a domain.
// customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-").
func GetExternalDNSIgnoredRecords(existing models.Records, domain string, customPrefix string) models.Records {
return filterExternalDNSRecords(existing, domain, customPrefix)
}

View file

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

View file

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

View file

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

View file

@ -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=<owner-id>,external-dns/resource=<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() {

View file

@ -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")
);

View file

@ -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
}
]
}