Merge branch 'main' into branch_allrecs

This commit is contained in:
Thomas Limoncelli 2025-12-03 20:42:10 -05:00
commit 691764ee29
No known key found for this signature in database
23 changed files with 1844 additions and 160 deletions

View file

@ -1671,6 +1671,196 @@ 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
*
* ### 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)
*
* @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

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

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

@ -2068,6 +2068,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

View file

@ -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
)
@ -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"`
@ -71,7 +74,7 @@ func (dc *DomainConfig) PostProcess() {
// Turn the user-supplied name into the fixed forms.
ff := domaintags.MakeDomainNameVarieties(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.
@ -79,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
}
@ -233,7 +236,7 @@ func (dc *DomainConfig) GetPopulateCorrections(providerName string) []*Correctio
func MakeFakeDomainConfig(domain string) *DomainConfig {
v := domaintags.MakeDomainNameVarieties(domain)
return &DomainConfig{
Name: v.NameIDN,
Name: v.NameASCII,
NameRaw: v.NameRaw,
NameUnicode: v.NameUnicode,
}

11
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"dependencies": {
"@umbrelladocs/linkspector": "^0.3.13",
"prettier": "^3.6.2"
"prettier": "^3.7.4"
}
}

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

@ -2,12 +2,14 @@ package domaintags
import (
"strings"
"golang.org/x/net/idna"
)
// 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"
@ -22,7 +24,8 @@ type DomainFixedForms struct {
// * .NameUnicode: unicode version of the name, downcased.
// * .UniqueName: "example.com!tag" unique across the entire config.
func MakeDomainNameVarieties(n string) DomainFixedForms {
var tag, nameRaw, nameIDN, nameUnicode, uniqueName string
var err error
var tag, nameRaw, nameASCII, nameUnicode, uniqueName string
var hasBang bool
// Split tag from name.
@ -35,25 +38,45 @@ func MakeDomainNameVarieties(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 = EfficientToASCII(nameRaw)
nameUnicode = EfficientToUnicode(nameRaw)
nameASCII, err = idna.ToASCII(nameRaw)
if err != nil {
nameASCII = nameRaw // Fallback to raw name on error.
} else {
nameASCII = strings.ToLower(nameASCII)
// Avoid pointless duplication.
if strings.HasPrefix(n, nameASCII) {
// Avoid pointless duplication.
nameASCII = n[0:len(nameASCII)]
}
}
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 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,

View file

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

View file

@ -24,8 +24,8 @@ func CompilePermitList(s string) PermitList {
continue
}
ff := MakeDomainNameVarieties(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
}
}

View file

@ -335,7 +335,15 @@ var AKAMAICDN = recordBuilder('AKAMAICDN');
var AKAMAITLC = recordBuilder('AKAMAITLC', {
args: [
['name', _.isString],
['answer_type', function (value) { return _.isString(value) && ['DUAL', 'A', 'AAAA'].indexOf(value) !== -1; }],
[
'answer_type',
function (value) {
return (
_.isString(value) &&
['DUAL', 'A', 'AAAA'].indexOf(value) !== -1
);
},
],
['target', _.isString],
],
transform: function (record, args, modifier) {
@ -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() {
@ -1785,7 +1816,7 @@ function CAA_BUILDER(value) {
throw 'CAA_BUILDER requires at least one entry at issue, issuewild, issuevmc or issuemail';
}
var CAA_TTL = function () { };
var CAA_TTL = function () {};
if (value.ttl) {
CAA_TTL = TTL(value.ttl);
}
@ -1802,7 +1833,7 @@ function CAA_BUILDER(value) {
}
if (value.issue) {
var flag = function () { };
var flag = function () {};
if (value.issue_critical) {
flag = CAA_CRITICAL;
}
@ -1811,7 +1842,7 @@ function CAA_BUILDER(value) {
}
if (value.issuewild) {
var flag = function () { };
var flag = function () {};
if (value.issuewild_critical) {
flag = CAA_CRITICAL;
}
@ -1822,7 +1853,7 @@ function CAA_BUILDER(value) {
}
if (value.issuevmc) {
var flag = function () { };
var flag = function () {};
if (value.issuevmc_critical) {
flag = CAA_CRITICAL;
}
@ -1833,7 +1864,7 @@ function CAA_BUILDER(value) {
}
if (value.issuemail) {
var flag = function () { };
var flag = function () {};
if (value.issuemail_critical) {
flag = CAA_CRITICAL;
}
@ -2052,7 +2083,7 @@ function DKIM_BUILDER(value) {
}
// Handle TTL
var DKIM_TTL = value.ttl ? TTL(value.ttl) : function () { };
var DKIM_TTL = value.ttl ? TTL(value.ttl) : function () {};
return TXT(fullLabel, record.join('; '), DKIM_TTL);
}
@ -2284,20 +2315,20 @@ function M365_BUILDER(name, value) {
CNAME(
'selector1._domainkey',
'selector1-' +
value.domainGUID +
'._domainkey.' +
value.initialDomain +
'.'
value.domainGUID +
'._domainkey.' +
value.initialDomain +
'.'
)
);
r.push(
CNAME(
'selector2._domainkey',
'selector2-' +
value.domainGUID +
'._domainkey.' +
value.initialDomain +
'.'
value.domainGUID +
'._domainkey.' +
value.initialDomain +
'.'
)
);
}

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 @@
{
"dns_providers": [],
"domains": [
{
"dnsProviders": {},
"ignore_external_dns": true,
"meta": {
"dnscontrol_uniquename": "extdns-default.com"
},
"name": "extdns-default.com",
"records": [],
"registrar": "none"
},
{
"dnsProviders": {},
"external_dns_prefix": "extdns-",
"ignore_external_dns": true,
"meta": {
"dnscontrol_uniquename": "extdns-custom.com"
},
"name": "extdns-custom.com",
"records": [],
"registrar": "none"
},
{
"dnsProviders": {},
"ignore_external_dns": true,
"meta": {
"dnscontrol_uniquename": "extdns-combined.com"
},
"name": "extdns-combined.com",
"records": [
{
"filepos": "[line:12:5]",
"name": "api",
"target": "www.extdns-combined.com.",
"ttl": 300,
"type": "CNAME"
},
{
"filepos": "[line:11:5]",
"name": "www",
"target": "1.2.3.4",
"ttl": 300,
"type": "A"
}
],
"registrar": "none"
}
],
"registrars": []
}

View file

@ -71,7 +71,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)
@ -173,7 +173,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],
}
@ -305,7 +305,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,
},

View file

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

View file

@ -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.MakeDomainNameVarieties(`EXAMple.com`)
ff2 := domaintags.MakeDomainNameVarieties(`EXAMple.com!myTag`)
ff3 := domaintags.MakeDomainNameVarieties(`рф.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},