Merge branch 'main' into branch_allrecs

This commit is contained in:
Thomas Limoncelli 2025-12-01 11:07:45 -05:00
commit e42dbcda57
No known key found for this signature in database
23 changed files with 1344 additions and 65 deletions

View file

@ -39,7 +39,7 @@ changelog:
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1 order: 1
- title: 'Provider-specific changes:' - title: 'Provider-specific changes:'
regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hetznerv2|hexonet|hostingde|huaweicloud|inwx|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*" regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hetznerv2|hexonet|hostingde|huaweicloud|inwx|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vercel|vultr).*:)+.*"
order: 2 order: 2
- title: 'Documentation:' - title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"

1
OWNERS
View file

@ -53,4 +53,5 @@ providers/rwth @mistererwin
providers/sakuracloud @ttkzw providers/sakuracloud @ttkzw
# providers/softlayer NEEDS VOLUNTEER # providers/softlayer NEEDS VOLUNTEER
providers/transip @blackshadev providers/transip @blackshadev
providers/vercel @SukkaW
providers/vultr @pgaskin providers/vultr @pgaskin

View file

@ -65,6 +65,7 @@ Currently supported DNS providers:
- Sakura Cloud - Sakura Cloud
- SoftLayer - SoftLayer
- TransIP - TransIP
- Vercel
- Vultr - Vultr
Currently supported Domain Registrars: Currently supported Domain Registrars:

View file

@ -166,6 +166,7 @@
* [Sakura Cloud](provider/sakuracloud.md) * [Sakura Cloud](provider/sakuracloud.md)
* [SoftLayer DNS](provider/softlayer.md) * [SoftLayer DNS](provider/softlayer.md)
* [TransIP](provider/transip.md) * [TransIP](provider/transip.md)
* [Vercel](provider/vercel.md)
* [Vultr](provider/vultr.md) * [Vultr](provider/vultr.md)
## Commands ## Commands

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -80,6 +80,7 @@ Jump to a table:
| [`SAKURACLOUD`](sakuracloud.md) | ❌ | ✅ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ✅ | ❌ |
| [`SOFTLAYER`](softlayer.md) | ❌ | ✅ | ❌ | | [`SOFTLAYER`](softlayer.md) | ❌ | ✅ | ❌ |
| [`TRANSIP`](transip.md) | ❌ | ✅ | ❌ | | [`TRANSIP`](transip.md) | ❌ | ✅ | ❌ |
| [`VERCEL`](vercel.md) | ❌ | ✅ | ❌ |
| [`VULTR`](vultr.md) | ❌ | ✅ | ❌ | | [`VULTR`](vultr.md) | ❌ | ✅ | ❌ |
@ -118,7 +119,7 @@ Jump to a table:
| [`HOSTINGDE`](hostingde.md) | ❔ | ✅ | ✅ | ✅ | | [`HOSTINGDE`](hostingde.md) | ❔ | ✅ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ |
| [`INTERNETBS`](internetbs.md) | ❔ | ❔ | ❌ | ❔ | | [`INTERNETBS`](internetbs.md) | ❔ | ❔ | ❌ | ❔ |
| [`INWX`](inwx.md) | | ✅ | ✅ | ✅ | | [`INWX`](inwx.md) | | ✅ | ✅ | ✅ |
| [`JOKER`](joker.md) | ❌ | ❌ | ✅ | ✅ | | [`JOKER`](joker.md) | ❌ | ❌ | ✅ | ✅ |
| [`LINODE`](linode.md) | ❔ | ❌ | ❌ | ✅ | | [`LINODE`](linode.md) | ❔ | ❌ | ❌ | ✅ |
| [`LOOPIA`](loopia.md) | ❔ | ✅ | ❌ | ✅ | | [`LOOPIA`](loopia.md) | ❔ | ✅ | ❌ | ✅ |
@ -141,6 +142,7 @@ Jump to a table:
| [`SAKURACLOUD`](sakuracloud.md) | ❔ | ❌ | ✅ | ✅ | | [`SAKURACLOUD`](sakuracloud.md) | ❔ | ❌ | ✅ | ✅ |
| [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ |
| [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ✅ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ✅ |
| [`VERCEL`](vercel.md) | ❔ | ❌ | ❌ | ❌ |
| [`VULTR`](vultr.md) | ❔ | ❔ | ✅ | ✅ | | [`VULTR`](vultr.md) | ❔ | ❔ | ✅ | ✅ |
@ -156,7 +158,7 @@ Jump to a table:
| [`AZURE_PRIVATE_DNS`](azure_private_dns.md) | ❌ | ❔ | ❌ | ✅ | ❔ | | [`AZURE_PRIVATE_DNS`](azure_private_dns.md) | ❌ | ❔ | ❌ | ✅ | ❔ |
| [`BIND`](bind.md) | ❔ | ✅ | ✅ | ✅ | ✅ | | [`BIND`](bind.md) | ❔ | ✅ | ✅ | ✅ | ✅ |
| [`BUNNY_DNS`](bunny_dns.md) | ✅ | ❔ | ❌ | ✅ | ❌ | | [`BUNNY_DNS`](bunny_dns.md) | ✅ | ❔ | ❌ | ✅ | ❌ |
| [`CLOUDFLAREAPI`](cloudflareapi.md) | ✅ | ❔ | | ✅ | ❔ | | [`CLOUDFLAREAPI`](cloudflareapi.md) | ✅ | ❔ | | ✅ | ❔ |
| [`CLOUDNS`](cloudns.md) | ✅ | ✅ | ✅ | ✅ | ❔ | | [`CLOUDNS`](cloudns.md) | ✅ | ✅ | ✅ | ✅ | ❔ |
| [`CNR`](cnr.md) | ✅ | ❌ | ❌ | ✅ | ❌ | | [`CNR`](cnr.md) | ✅ | ❌ | ❌ | ✅ | ❌ |
| [`DESEC`](desec.md) | ❔ | ❔ | ❔ | ✅ | ❔ | | [`DESEC`](desec.md) | ❔ | ❔ | ❔ | ✅ | ❔ |
@ -197,6 +199,7 @@ Jump to a table:
| [`SAKURACLOUD`](sakuracloud.md) | ✅ | ❌ | ❌ | ✅ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ✅ | ❌ | ❌ | ✅ | ❌ |
| [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | ❔ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | ❔ |
| [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ❌ | ❌ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ❌ | ❌ |
| [`VERCEL`](vercel.md) | ✅ | ❌ | ❌ | ❌ | ❌ |
| [`VULTR`](vultr.md) | ❌ | ❔ | ❌ | ❌ | ❔ | | [`VULTR`](vultr.md) | ❌ | ❔ | ❌ | ❌ | ❔ |
@ -251,6 +254,7 @@ Jump to a table:
| [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ✅ | ✅ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ✅ | ✅ |
| [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ✅ | ❔ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ✅ | ❔ |
| [`TRANSIP`](transip.md) | ❌ | ✅ | ✅ | ❌ | | [`TRANSIP`](transip.md) | ❌ | ✅ | ✅ | ❌ |
| [`VERCEL`](vercel.md) | ❌ | ❌ | ✅ | ❌ |
| [`VULTR`](vultr.md) | ❔ | ❔ | ✅ | ❔ | | [`VULTR`](vultr.md) | ❔ | ❔ | ✅ | ❔ |
@ -303,6 +307,7 @@ Jump to a table:
| [`RWTH`](rwth.md) | ✅ | ❔ | ❔ | ✅ | ❌ | | [`RWTH`](rwth.md) | ✅ | ❔ | ❔ | ✅ | ❌ |
| [`SAKURACLOUD`](sakuracloud.md) | ✅ | ✅ | ❔ | ❌ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ✅ | ✅ | ❔ | ❌ | ❌ |
| [`TRANSIP`](transip.md) | ✅ | ❌ | ❔ | ✅ | ✅ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❔ | ✅ | ✅ |
| [`VERCEL`](vercel.md) | ✅ | ✅ | ❔ | ❌ | ❌ |
| [`VULTR`](vultr.md) | ✅ | ❔ | ❔ | ✅ | ❌ | | [`VULTR`](vultr.md) | ✅ | ❔ | ❔ | ✅ | ❌ |
@ -339,6 +344,7 @@ Jump to a table:
| [`REALTIMEREGISTER`](realtimeregister.md) | ✅ | ❔ | ❌ | | [`REALTIMEREGISTER`](realtimeregister.md) | ✅ | ❔ | ❌ |
| [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ❌ |
| [`TRANSIP`](transip.md) | ❌ | ❌ | ❌ | | [`TRANSIP`](transip.md) | ❌ | ❌ | ❌ |
| [`VERCEL`](vercel.md) | ❌ | ❌ | ❌ |
<!-- provider-matrix-end --> <!-- provider-matrix-end -->
@ -428,6 +434,7 @@ Providers in this category and their maintainers are:
|[`SAKURACLOUD`](sakuracloud.md)|@ttkzw| |[`SAKURACLOUD`](sakuracloud.md)|@ttkzw|
|[`SOFTLAYER`](softlayer.md)|@jamielennox| |[`SOFTLAYER`](softlayer.md)|@jamielennox|
|[`TRANSIP`](transip.md)|@blackshadev| |[`TRANSIP`](transip.md)|@blackshadev|
|[`VERCEL`](vercel.md)|@SukkaW|
|[`VULTR`](vultr.md)|@pgaskin| |[`VULTR`](vultr.md)|@pgaskin|
### Requested providers ### Requested providers
@ -458,7 +465,6 @@ code to support this provider, we'd be glad to help in any way.
* [Spaceship](https://github.com/StackExchange/dnscontrol/issues/3452) (#3452) * [Spaceship](https://github.com/StackExchange/dnscontrol/issues/3452) (#3452)
* [SynergyWholesale](https://github.com/StackExchange/dnscontrol/issues/1605) (#1605) * [SynergyWholesale](https://github.com/StackExchange/dnscontrol/issues/1605) (#1605)
* [UltraDNS by Neustar / CSCGlobal](https://github.com/StackExchange/dnscontrol/issues/1533) (#1533) * [UltraDNS by Neustar / CSCGlobal](https://github.com/StackExchange/dnscontrol/issues/1533) (#1533)
* [Vercel](https://github.com/StackExchange/dnscontrol/issues/3379) (#3379)
* [Yandex Cloud DNS](https://github.com/StackExchange/dnscontrol/issues/3737) (#3737) * [Yandex Cloud DNS](https://github.com/StackExchange/dnscontrol/issues/3737) (#3737)
#### Q: Why are the above GitHub issues marked "closed"? #### Q: Why are the above GitHub issues marked "closed"?

View file

@ -0,0 +1,144 @@
## Configuration
To use this provider, add an entry to `creds.json` with `TYPE` set to `VERCEL`
along with a [Vercel API Token](https://vercel.com/account/settings/tokens) and your team ID.
Example:
{% code title="creds.json" %}
```json
{
"vercel": {
"TYPE": "VERCEL",
"team_id": "$VERCEL_TEAM_ID",
"api_token": "$VERCEL_API_TOKEN"
}
}
```
{% endcode %}
**API Token**
You can create a Vercel API Token via [Vercel Account Settngs](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)
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.
![Example permissions configuration](../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`.
**Legacy Vercel Account Domains**
If you are an early Vercel user (when Vercel didn't implement teams back then), the domains you added back then might not be migrated to your personal team. You will be able to find and manage those domains in Vercel's [Account Settings - Overview - Domains](https://vercel.com/account/domains).
In this case, you should use an empty string as the team ID:
{% code title="creds.json" %}
```json
{
"vercel": {
"TYPE": "VERCEL",
"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.
## Usage
An example configuration:
{% code title="dnsconfig.js" %}
```javascript
var REG_NONE = NewRegistrar("none");
var DSP_VERCEL = NewDnsProvider("vercel");
D("example.com", REG_NONE, DnsProvider(DSP_VERCEL),
A("test", "1.2.3.4"),
);
```
{% endcode %}
## Caveats
### New domains
We do not support adding a domain to Vercel via `dnscontrol push`, as Vercel now requires a domain be associated with a project before it can utilize Vercel's DNS. You should use Vercel's [DNS Dashboard](https://vercel.com/dashboard/domains) to add a domain.
### System-managed Records
Vercel will create "system-managed records" for you when you add a domain to Vercel. Those records can not be deleted or modified.
You can add your own records and Vercel will prefer your created records over their system-managed records, but the system-managed records will always be present even if you add your own "override" records.
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.
- `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).
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`.
### Comment
This provider does not recognize Vercel DNS record comment. And we encourage you not to use it. You should use JavaScript comment in your `dnsconfig.js` instead.
In the future, we might use the comment field to store additional metadata for other purposes.
### CAA
As of November 2025, Vercel has a bug that does not accept CAA records with any extra fields that are not `cansigncansignhttpexchanges`:
```
# OK
CAA 0 issue "letsencrypt.org"
CAA 0 issuewild "letsencrypt.org"
CAA 0 issue "digicert.com; cansignhttpexchanges=yes"
# Panic
CAA 0 issue "letsencrypt.org; validationmethods=dns-01"
CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234"
```
This is most likely a bug on Vercel's side where Vercel misinterprets other fields as `cansignhttpexchanges`, as the API error response implies:
```
invalid_value - Unexpected "cansignhttpexchanges" value.
```
### Rate Limiting
Vercel is rate limiting requests somewhat heavily. Some of the rate limit and remaining quota is advertised in the API response headers (`x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset`), some of HTTP 429 contains the `retry-after` response header, some of the rate limit rules can be found in [Vercel's API documentation](https://vercel.com/docs/limits#rate-limits).
So far, the known rate limit rules are:
- create up to 100 dns records per hour (3600 seconds)
- update up to 50 dns records per minutes (60 seconds)
The rate limit rules for the following actions are unknown:
- list dns records - we assume 50 page per minute (60 seconds)
- delete dns records - we assume 50 dns records per minute (60 seconds)
All operations do not share rate limit quota, each operation has its own rate limit quota.
We will burst through half of the quota, and then it spreads the requests evenly throughout the remaining window. This allows you to move fast and be able to revert accidental changes to the DNS config in a somewhat timely manner. We will retry rate-limited requests (status 429) and respect the advertised `Retry-After` delay.
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.
### 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.

3
go.mod
View file

@ -74,6 +74,7 @@ require (
github.com/nicholas-fedor/shoutrrr v0.12.0 github.com/nicholas-fedor/shoutrrr v0.12.0
github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481
github.com/oracle/oci-go-sdk/v65 v65.104.0 github.com/oracle/oci-go-sdk/v65 v65.104.0
github.com/vercel/terraform-provider-vercel v1.14.1
github.com/vultr/govultr/v2 v2.17.2 github.com/vultr/govultr/v2 v2.17.2
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/text v0.31.0 golang.org/x/text v0.31.0
@ -122,7 +123,9 @@ require (
github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect

11
go.sum
View file

@ -126,6 +126,7 @@ github.com/exoscale/egoscale v0.102.4/go.mod h1:ROSmPtle0wvf91iLZb09++N/9BH2Jo9X
github.com/failsafe-go/failsafe-go v0.9.1 h1:PkKSKLSOPRyJMjx35SfuwQeDuPLB6lBhD+zpQcSe7NU= github.com/failsafe-go/failsafe-go v0.9.1 h1:PkKSKLSOPRyJMjx35SfuwQeDuPLB6lBhD+zpQcSe7NU=
github.com/failsafe-go/failsafe-go v0.9.1/go.mod h1:sX5TZ4HrMLYSzErWeckIHRZWgZj9PbKMAEKOVLFWtfM= github.com/failsafe-go/failsafe-go v0.9.1/go.mod h1:sX5TZ4HrMLYSzErWeckIHRZWgZj9PbKMAEKOVLFWtfM=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -233,6 +234,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow=
github.com/hetznercloud/hcloud-go/v2 v2.30.0 h1:fgAUtCCw4PbJNSs9XPLHVu0//dTNMbPq8P/48ovmdG8= github.com/hetznercloud/hcloud-go/v2 v2.30.0 h1:fgAUtCCw4PbJNSs9XPLHVu0//dTNMbPq8P/48ovmdG8=
github.com/hetznercloud/hcloud-go/v2 v2.30.0/go.mod h1:zv7x2kM7xyJ5mW/+y4HbfxQYhk8TE57ypTa1hofsYdw= github.com/hetznercloud/hcloud-go/v2 v2.30.0/go.mod h1:zv7x2kM7xyJ5mW/+y4HbfxQYhk8TE57ypTa1hofsYdw=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 h1:FBlx7E5rl8doUTbizt+DXR0zU05Mu2oEYvc/2GMB7pc= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 h1:FBlx7E5rl8doUTbizt+DXR0zU05Mu2oEYvc/2GMB7pc=
@ -286,7 +289,9 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@ -299,6 +304,8 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mittwald/go-powerdns v0.6.7 h1:r638QOYLWyJ5Wy+qynlq5nTRlhmfQMJvM9BDsbhyiro= github.com/mittwald/go-powerdns v0.6.7 h1:r638QOYLWyJ5Wy+qynlq5nTRlhmfQMJvM9BDsbhyiro=
github.com/mittwald/go-powerdns v0.6.7/go.mod h1:zFe/i17IP6/NGFkWGGsPL0t7VrL6u14HU8Hr06X4Qmg= github.com/mittwald/go-powerdns v0.6.7/go.mod h1:zFe/i17IP6/NGFkWGGsPL0t7VrL6u14HU8Hr06X4Qmg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -395,6 +402,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@ -411,6 +419,8 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg=
github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI=
github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs=
github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI=
github.com/xddxdd/ottoext v0.0.0-20221109171055-210517fa4419 h1:PT5KYEimicg1GRkBtBxCLcHWvMcBRGljOLwG/y4+T5c= github.com/xddxdd/ottoext v0.0.0-20221109171055-210517fa4419 h1:PT5KYEimicg1GRkBtBxCLcHWvMcBRGljOLwG/y4+T5c=
@ -534,6 +544,7 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -418,6 +418,7 @@ func makeTests() []*TestGroup {
"NETCUP", // NS records not currently supported. "NETCUP", // NS records not currently supported.
"SAKURACLOUD", // Silently ignores requests to remove NS at @. "SAKURACLOUD", // Silently ignores requests to remove NS at @.
"TRANSIP", // "it is not allowed to have an NS for an @ record" "TRANSIP", // "it is not allowed to have an NS for an @ record"
"VERCEL", // "invalid_name - Cannot set NS records at the root level. Only subdomain NS records are supported"
), ),
tc("Single NS at apex", ns("@", "ns1.foo.com.")), tc("Single NS at apex", ns("@", "ns1.foo.com.")),
tc("Dual NS at apex", ns("@", "ns2.foo.com."), ns("@", "ns1.foo.com.")), tc("Dual NS at apex", ns("@", "ns2.foo.com."), ns("@", "ns1.foo.com.")),
@ -628,6 +629,7 @@ func makeTests() []*TestGroup {
// Notes: // Notes:
// - Gandi: page size is 100, therefore we test with 99, 100, and 101 // - Gandi: page size is 100, therefore we test with 99, 100, and 101
// - DIGITALOCEAN: page size is 100 (default: 20) // - DIGITALOCEAN: page size is 100 (default: 20)
// - VERCEL: up to 100 per pages
not( not(
"AZURE_DNS", // Removed because it is too slow "AZURE_DNS", // Removed because it is too slow
"CLOUDFLAREAPI", // Infinite pagesize but due to slow speed, skipping. "CLOUDFLAREAPI", // Infinite pagesize but due to slow speed, skipping.
@ -644,6 +646,7 @@ func makeTests() []*TestGroup {
"TRANSIP", // Doesn't page. Works fine. Due to the slow API we skip. "TRANSIP", // Doesn't page. Works fine. Due to the slow API we skip.
"CNR", // Test beaks limits. "CNR", // Test beaks limits.
"FORTIGATE", // No paging "FORTIGATE", // No paging
"VERCEL", // Rate limit 100 creation per hour, 101 needs an hour, too much
), ),
tc("99 records", manyA("pager101-rec%04d", "1.2.3.4", 99)...), tc("99 records", manyA("pager101-rec%04d", "1.2.3.4", 99)...),
tc("100 records", manyA("pager101-rec%04d", "1.2.3.4", 100)...), tc("100 records", manyA("pager101-rec%04d", "1.2.3.4", 100)...),
@ -1351,6 +1354,20 @@ func makeTests() []*TestGroup {
tc("simple", aghAAAAPassthrough("foo", "")), tc("simple", aghAAAAPassthrough("foo", "")),
), ),
// VERCEL features(?)
// Turns out that Vercel does support whitespace in the CAA record,
// but it only supports `cansignhttpexchanges` field, all other fields,
// `validationmethods`, `accounturi` are not supported
//
// In order to test the `CAA whitespace` capabilities and quirks, let's go!
testgroup("VERCEL CAA whitespace - cansignhttpexchanges",
only(
"VERCEL",
),
tc("CAA whitespace - cansignhttpexchanges", caa("@", 128, "issue", "digicert.com; cansignhttpexchanges=yes")),
),
//// IGNORE* features //// IGNORE* features
// Narrative: You're basically done now. These remaining tests // Narrative: You're basically done now. These remaining tests

View file

@ -340,6 +340,12 @@
"TYPE": "TRANSIP", "TYPE": "TRANSIP",
"domain": "$TRANSIP_DOMAIN" "domain": "$TRANSIP_DOMAIN"
}, },
"VERCEL": {
"TYPE": "VERCEL",
"api_token": "$VERCEL_API_TOKEN",
"domain": "$VERCEL_DOMAIN",
"team_id": "$VERCEL_TEAM_ID"
},
"VULTR": { "VULTR": {
"TYPE": "VULTR", "TYPE": "VULTR",
"domain": "$VULTR_DOMAIN", "domain": "$VULTR_DOMAIN",

View file

@ -99,26 +99,20 @@ func (rc *RecordConfig) calculateLOCFields(d1 uint8, m1 uint8, s1 float32, ns st
) error { ) error {
// Crazy hairy shit happens here. // Crazy hairy shit happens here.
// We already got the useful "string" version earlier. ¯\_(ツ)_/¯ code golf... // We already got the useful "string" version earlier. ¯\_(ツ)_/¯ code golf...
const LOCEquator uint64 = 0x80000000 // 1 << 31 // RFC 1876, Section 2. lat := uint32(d1)*dns.LOC_DEGREES + uint32(m1)*dns.LOC_HOURS + uint32(s1*1000)
const LOCPrimeMeridian uint64 = 0x80000000 // 1 << 31 // RFC 1876, Section 2. lon := uint32(d2)*dns.LOC_DEGREES + uint32(m2)*dns.LOC_HOURS + uint32(s2*1000)
const LOCHours uint32 = 60 * 1000
const LOCDegrees = 60 * LOCHours
const LOCAltitudeBase int32 = 100000
lat := uint64((uint32(d1) * LOCDegrees) + (uint32(m1) * LOCHours) + uint32(s1*1000))
lon := uint64((uint32(d2) * LOCDegrees) + (uint32(m2) * LOCHours) + uint32(s2*1000))
if strings.ToUpper(ns) == "N" { if strings.ToUpper(ns) == "N" {
rc.LocLatitude = uint32(LOCEquator + lat) rc.LocLatitude = dns.LOC_EQUATOR + lat
} else { // "S" } else { // "S"
rc.LocLatitude = uint32(LOCEquator - lat) rc.LocLatitude = dns.LOC_EQUATOR - lat
} }
if strings.ToUpper(ew) == "E" { if strings.ToUpper(ew) == "E" {
rc.LocLongitude = uint32(LOCPrimeMeridian + lon) rc.LocLongitude = dns.LOC_PRIMEMERIDIAN + lon
} else { // "W" } else { // "W"
rc.LocLongitude = uint32(LOCPrimeMeridian - lon) rc.LocLongitude = dns.LOC_PRIMEMERIDIAN - lon
} }
// Altitude // Altitude
altitude := (float64(al) + float64(LOCAltitudeBase)) * 100 altitude := (float64(al) + dns.LOC_ALTITUDEBASE) * 100
clampedAltitude := math.Min(math.Max(0, altitude), float64(math.MaxUint32)) clampedAltitude := math.Min(math.Max(0, altitude), float64(math.MaxUint32))
rc.LocAltitude = uint32(clampedAltitude) rc.LocAltitude = uint32(clampedAltitude)
@ -205,3 +199,54 @@ func getENotationInt(x float32) (uint8, error) {
return packedValue, nil return packedValue, nil
} }
func ReverseLatitude(lat uint32) (string, uint8, uint8, float64) {
var hemisphere string
if lat >= dns.LOC_EQUATOR {
hemisphere = "N"
lat = lat - dns.LOC_EQUATOR
} else {
hemisphere = "S"
lat = dns.LOC_EQUATOR - lat
}
degrees := uint8(lat / dns.LOC_DEGREES)
lat -= uint32(degrees) * dns.LOC_DEGREES
minutes := uint8(lat / dns.LOC_HOURS)
lat -= uint32(minutes) * dns.LOC_HOURS
seconds := float64(lat) / 1000
return hemisphere, degrees, minutes, seconds
}
func ReverseLongitude(lon uint32) (string, uint8, uint8, float64) {
var hemisphere string
if lon >= dns.LOC_PRIMEMERIDIAN {
hemisphere = "E"
lon = lon - dns.LOC_PRIMEMERIDIAN
} else {
hemisphere = "W"
lon = dns.LOC_PRIMEMERIDIAN - lon
}
degrees := uint8(lon / dns.LOC_DEGREES)
lon -= uint32(degrees) * dns.LOC_DEGREES
minutes := uint8(lon / dns.LOC_HOURS)
lon -= uint32(minutes) * dns.LOC_HOURS
seconds := float64(lon) / 1000
return hemisphere, degrees, minutes, seconds
}
func ReverseAltitude(packedAltitude uint32) float64 {
return float64(packedAltitude)/100 - 100000
}
// ReverseENotationInt produces a number from a mantissa_exponent 4bits:4bits uint8
func ReverseENotationInt(packedValue uint8) float64 {
mantissa := float64((packedValue >> 4) & 0x0F)
exponent := int(packedValue & 0x0F)
centimeters := mantissa * math.Pow10(exponent)
// Return in meters
return centimeters / 100
}

View file

@ -58,5 +58,6 @@ import (
_ "github.com/StackExchange/dnscontrol/v4/providers/sakuracloud" _ "github.com/StackExchange/dnscontrol/v4/providers/sakuracloud"
_ "github.com/StackExchange/dnscontrol/v4/providers/softlayer" _ "github.com/StackExchange/dnscontrol/v4/providers/softlayer"
_ "github.com/StackExchange/dnscontrol/v4/providers/transip" _ "github.com/StackExchange/dnscontrol/v4/providers/transip"
_ "github.com/StackExchange/dnscontrol/v4/providers/vercel"
_ "github.com/StackExchange/dnscontrol/v4/providers/vultr" _ "github.com/StackExchange/dnscontrol/v4/providers/vultr"
) )

View file

@ -53,7 +53,7 @@ var features = providers.DocumentationNotes{
providers.CanUseDS: providers.Can(), providers.CanUseDS: providers.Can(),
providers.CanUseDSForChildren: providers.Can(), providers.CanUseDSForChildren: providers.Can(),
providers.CanUseHTTPS: providers.Can(), providers.CanUseHTTPS: providers.Can(),
providers.CanUseLOC: providers.Cannot(), providers.CanUseLOC: providers.Can(),
providers.CanUseNAPTR: providers.Can(), providers.CanUseNAPTR: providers.Can(),
providers.CanUsePTR: providers.Can(), providers.CanUsePTR: providers.Can(),
providers.CanUseSRV: providers.Can(), providers.CanUseSRV: providers.Can(),
@ -657,28 +657,40 @@ func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNS
// Used on the "existing" records. // Used on the "existing" records.
type cfRecData struct { type cfRecData struct {
Name string `json:"name"` Name string `json:"name"`
Target cfTarget `json:"target"` Target cfTarget `json:"target"`
Service string `json:"service"` // SRV Service string `json:"service"` // SRV
Proto string `json:"proto"` // SRV Proto string `json:"proto"` // SRV
Priority uint16 `json:"priority"` // SRV Priority uint16 `json:"priority"` // SRV
Weight uint16 `json:"weight"` // SRV Weight uint16 `json:"weight"` // SRV
Port uint16 `json:"port"` // SRV Port uint16 `json:"port"` // SRV
Tag string `json:"tag"` // CAA Tag string `json:"tag"` // CAA
Flags uint16 `json:"flags"` // CAA/DNSKEY Flags uint16 `json:"flags"` // CAA/DNSKEY
Value string `json:"value"` // CAA Value string `json:"value"` // CAA
Usage uint8 `json:"usage"` // TLSA Usage uint8 `json:"usage"` // TLSA
Selector uint8 `json:"selector"` // TLSA Selector uint8 `json:"selector"` // TLSA
MatchingType uint8 `json:"matching_type"` // TLSA MatchingType uint8 `json:"matching_type"` // TLSA
Certificate string `json:"certificate"` // TLSA Certificate string `json:"certificate"` // TLSA
Algorithm uint8 `json:"algorithm"` // SSHFP/DNSKEY/DS Algorithm uint8 `json:"algorithm"` // SSHFP/DNSKEY/DS
HashType uint8 `json:"type"` // SSHFP HashType uint8 `json:"type"` // SSHFP
Fingerprint string `json:"fingerprint"` // SSHFP Fingerprint string `json:"fingerprint"` // SSHFP
Protocol uint8 `json:"protocol"` // DNSKEY Protocol uint8 `json:"protocol"` // DNSKEY
PublicKey string `json:"public_key"` // DNSKEY PublicKey string `json:"public_key"` // DNSKEY
KeyTag uint16 `json:"key_tag"` // DS KeyTag uint16 `json:"key_tag"` // DS
DigestType uint8 `json:"digest_type"` // DS DigestType uint8 `json:"digest_type"` // DS
Digest string `json:"digest"` // DS Digest string `json:"digest"` // DS
Altitude float64 `json:"altitude"` // LOC
LatDegrees uint8 `json:"lat_degrees"` // LOC
LatDirection string `json:"lat_direction"` // LOC
LatMinutes uint8 `json:"lat_minutes"` // LOC
LatSeconds float64 `json:"lat_seconds"` // LOC
LongDegrees uint8 `json:"long_degrees"` // LOC
LongDirection string `json:"long_direction"` // LOC
LongMinutes uint8 `json:"long_minutes"` // LOC
LongSeconds float64 `json:"long_seconds"` // LOC
PrecisionHorz float64 `json:"precision_horz"` // LOC
PrecisionVert float64 `json:"precision_vert"` // LOC
Size float64 `json:"size"` // LOC
} }
// cfTarget is a SRV target. A null target is represented by an empty string, but // cfTarget is a SRV target. A null target is represented by an empty string, but

View file

@ -138,6 +138,26 @@ func cfSvcbData(rec *models.RecordConfig) *cfRecData {
} }
} }
func cfLocData(rec *models.RecordConfig) *cfRecData {
latDir, latDeg, latMin, latSec := models.ReverseLatitude(rec.LocLatitude)
longDir, longDeg, longMin, longSec := models.ReverseLongitude(rec.LocLongitude)
return &cfRecData{
Altitude: models.ReverseAltitude(rec.LocAltitude),
LatDegrees: latDeg,
LatDirection: latDir,
LatMinutes: latMin,
LatSeconds: latSec,
LongDegrees: longDeg,
LongDirection: longDir,
LongMinutes: longMin,
LongSeconds: longSec,
PrecisionHorz: models.ReverseENotationInt(rec.LocHorizPre),
PrecisionVert: models.ReverseENotationInt(rec.LocVertPre),
Size: models.ReverseENotationInt(rec.LocSize),
}
}
func cfNaptrData(rec *models.RecordConfig) *cfNaptrRecData { func cfNaptrData(rec *models.RecordConfig) *cfNaptrRecData {
return &cfNaptrRecData{ return &cfNaptrRecData{
Flags: rec.NaptrFlags, Flags: rec.NaptrFlags,
@ -155,13 +175,12 @@ func (c *cloudflareProvider) createRecDiff2(rec *models.RecordConfig, domainID s
content = rec.Metadata[metaOriginalIP] content = rec.Metadata[metaOriginalIP]
} }
prio := "" prio := ""
if rec.Type == "MX" { switch rec.Type {
case "MX":
prio = fmt.Sprintf(" %d ", rec.MxPreference) prio = fmt.Sprintf(" %d ", rec.MxPreference)
} case "TXT":
if rec.Type == "TXT" {
content = rec.GetTargetTXTJoined() content = rec.GetTargetTXTJoined()
} case "DS":
if rec.Type == "DS" {
content = fmt.Sprintf("%d %d %d %s", rec.DsKeyTag, rec.DsAlgorithm, rec.DsDigestType, rec.DsDigest) content = fmt.Sprintf("%d %d %d %s", rec.DsKeyTag, rec.DsAlgorithm, rec.DsDigestType, rec.DsDigest)
} }
if msg == "" { if msg == "" {
@ -180,28 +199,31 @@ func (c *cloudflareProvider) createRecDiff2(rec *models.RecordConfig, domainID s
Content: content, Content: content,
Priority: &rec.MxPreference, Priority: &rec.MxPreference,
} }
if rec.Type == "SRV" { switch rec.Type {
case "SRV":
cf.Data = cfSrvData(rec) cf.Data = cfSrvData(rec)
cf.Name = rec.GetLabelFQDN() cf.Name = rec.GetLabelFQDN()
} else if rec.Type == "CAA" { case "CAA":
cf.Data = cfCaaData(rec) cf.Data = cfCaaData(rec)
cf.Name = rec.GetLabelFQDN() cf.Name = rec.GetLabelFQDN()
cf.Content = "" cf.Content = ""
} else if rec.Type == "TLSA" { case "TLSA":
cf.Data = cfTlsaData(rec) cf.Data = cfTlsaData(rec)
cf.Name = rec.GetLabelFQDN() cf.Name = rec.GetLabelFQDN()
} else if rec.Type == "SSHFP" { case "SSHFP":
cf.Data = cfSshfpData(rec) cf.Data = cfSshfpData(rec)
cf.Name = rec.GetLabelFQDN() cf.Name = rec.GetLabelFQDN()
} else if rec.Type == "DNSKEY" { case "DNSKEY":
cf.Data = cfDnskeyData(rec) cf.Data = cfDnskeyData(rec)
} else if rec.Type == "DS" { case "DS":
cf.Data = cfDSData(rec) cf.Data = cfDSData(rec)
} else if rec.Type == "NAPTR" { case "NAPTR":
cf.Data = cfNaptrData(rec) cf.Data = cfNaptrData(rec)
cf.Name = rec.GetLabelFQDN() cf.Name = rec.GetLabelFQDN()
} else if rec.Type == "HTTPS" || rec.Type == "SVCB" { case "HTTPS", "SVCB":
cf.Data = cfSvcbData(rec) cf.Data = cfSvcbData(rec)
case "LOC":
cf.Data = cfLocData(rec)
} }
resp, err := c.cfClient.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), cf) resp, err := c.cfClient.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), cf)
if err != nil { if err != nil {
@ -233,33 +255,35 @@ func (c *cloudflareProvider) modifyRecord(domainID, recID string, proxied bool,
Priority: &rec.MxPreference, Priority: &rec.MxPreference,
TTL: int(rec.TTL), TTL: int(rec.TTL),
} }
if rec.Type == "TXT" { switch rec.Type {
case "TXT":
r.Content = rec.GetTargetTXTJoined() r.Content = rec.GetTargetTXTJoined()
} case "SRV":
if rec.Type == "SRV" {
r.Data = cfSrvData(rec) r.Data = cfSrvData(rec)
r.Name = rec.GetLabelFQDN() r.Name = rec.GetLabelFQDN()
} else if rec.Type == "CAA" { case "CAA":
r.Data = cfCaaData(rec) r.Data = cfCaaData(rec)
r.Name = rec.GetLabelFQDN() r.Name = rec.GetLabelFQDN()
r.Content = "" r.Content = ""
} else if rec.Type == "TLSA" { case "TLSA":
r.Data = cfTlsaData(rec) r.Data = cfTlsaData(rec)
r.Name = rec.GetLabelFQDN() r.Name = rec.GetLabelFQDN()
} else if rec.Type == "SSHFP" { case "SSHFP":
r.Data = cfSshfpData(rec) r.Data = cfSshfpData(rec)
r.Name = rec.GetLabelFQDN() r.Name = rec.GetLabelFQDN()
} else if rec.Type == "DNSKEY" { case "DNSKEY":
r.Data = cfDnskeyData(rec) r.Data = cfDnskeyData(rec)
r.Content = "" r.Content = ""
} else if rec.Type == "DS" { case "DS":
r.Data = cfDSData(rec) r.Data = cfDSData(rec)
r.Content = "" r.Content = ""
} else if rec.Type == "NAPTR" { case "NAPTR":
r.Data = cfNaptrData(rec) r.Data = cfNaptrData(rec)
r.Name = rec.GetLabelFQDN() r.Name = rec.GetLabelFQDN()
} else if rec.Type == "HTTPS" || rec.Type == "SVCB" { case "HTTPS", "SVCB":
r.Data = cfSvcbData(rec) r.Data = cfSvcbData(rec)
case "LOC":
r.Data = cfLocData(rec)
} }
_, err := c.cfClient.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), r) _, err := c.cfClient.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), r)
return err return err

View file

@ -1,6 +1,8 @@
package hetzner package hetzner
import ( import (
"strings"
"github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil" "github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
) )
@ -89,7 +91,12 @@ func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) {
TTL: *r.TTL, TTL: *r.TTL,
Original: r, Original: r,
} }
rc.SetLabel(r.Name, domain) if strings.HasSuffix(r.Name, "."+domain+".") {
// Records created through other tools or the browser UI can contain FQDN labels.
rc.SetLabelFromFQDN(r.Name, domain)
} else {
rc.SetLabel(r.Name, domain)
}
// HACK: Hetzner is inserting a trailing space after multiple, quoted values. // HACK: Hetzner is inserting a trailing space after multiple, quoted values.
// NOTE: The actual DNS answer does not contain the space. // NOTE: The actual DNS answer does not contain the space.

View file

@ -51,7 +51,7 @@ var features = providers.DocumentationNotes{
// See providers/capabilities.go for the entire list of capabilities. // See providers/capabilities.go for the entire list of capabilities.
providers.CanAutoDNSSEC: providers.Can(), providers.CanAutoDNSSEC: providers.Can(),
providers.CanGetZones: providers.Can(), providers.CanGetZones: providers.Can(),
providers.CanConcur: providers.Unimplemented(), providers.CanConcur: providers.Can(),
providers.CanUseAlias: providers.Can(), providers.CanUseAlias: providers.Can(),
providers.CanUseCAA: providers.Can(), providers.CanUseCAA: providers.Can(),
providers.CanUseDS: providers.Unimplemented("DS records are only supported at the apex and require a different API call that hasn't been implemented yet."), providers.CanUseDS: providers.Unimplemented("DS records are only supported at the apex and require a different API call that hasn't been implemented yet."),

171
providers/vercel/api.go Normal file
View file

@ -0,0 +1,171 @@
package vercel
import (
"context"
"encoding/json"
"fmt"
"net/http"
vercelClient "github.com/vercel/terraform-provider-vercel/client"
)
// DNSRecord is a helper struct to unmarshal the JSON response.
// It embeds vercelClient.DNSRecord to reuse the upstream type,
// but adds fields to handle API inconsistencies (type vs recordType, mxPriority).
type DNSRecord struct {
vercelClient.DNSRecord
Type string `json:"type"`
// Normally MXPriority would be uint16 type, but since vercelClient.DNSRecord uses int64, we'd better be consistent here
// Later in GetZoneRecords we do a `uint16OrZero` to ensure the type is correct
MXPriority int64 `json:"mxPriority"`
}
// pagination represents the pagination object in Vercel API responses.
type pagination struct {
Count int64 `json:"count"`
Next *int64 `json:"next"`
Prev *int64 `json:"prev"`
}
// listResponse represents the response from the Vercel List DNS Records API.
type listResponse struct {
Records []DNSRecord `json:"records"`
Pagination pagination `json:"pagination"`
}
// Vercel API limit is max 100
const vercelAPIPaginationLimit = 100
// ListDNSRecords retrieves all DNS records for a domain, handling pagination.
func (c *vercelProvider) ListDNSRecords(ctx context.Context, domain string) ([]DNSRecord, error) {
var allRecords []DNSRecord
var nextTimestamp int64
for {
url := fmt.Sprintf("https://api.vercel.com/v4/domains/%s/records?limit=%d", domain, vercelAPIPaginationLimit)
if c.teamID != "" {
url += fmt.Sprintf("&teamId=%s", c.teamID)
}
if nextTimestamp != 0 {
url += fmt.Sprintf("&until=%d", nextTimestamp)
}
var result listResponse
err := c.doRequest(clientRequest{
ctx: ctx,
method: http.MethodGet,
url: url,
}, &result, c.listLimiter)
if err != nil {
return nil, fmt.Errorf("failed to list DNS records: %w", err)
}
for _, r := range result.Records {
// The official SDK expects 'recordType' but the API returns 'type'.
// We explicitly map it here to fix the discrepancy.
r.RecordType = r.Type
// Ensure Domain field is set (it might not be in the record object itself)
if r.Domain == "" {
r.Domain = domain
}
if r.TeamID == "" {
r.TeamID = c.teamID
}
allRecords = append(allRecords, r)
}
if result.Pagination.Next == nil {
break
}
nextTimestamp = *result.Pagination.Next
}
return allRecords, nil
}
// httpsRecord structure for Vercel API
type httpsRecord struct {
Priority int64 `json:"priority"`
Target string `json:"target"`
Params string `json:"params,omitempty"`
}
// createDNSRecordRequest embeds the official SDK request but adds HTTPS support
type createDNSRecordRequest struct {
vercelClient.CreateDNSRecordRequest
HTTPS *httpsRecord `json:"https,omitempty"`
}
// CreateDNSRecord creates a DNS record.
func (c *vercelProvider) CreateDNSRecord(ctx context.Context, req createDNSRecordRequest) (*vercelClient.DNSRecord, error) {
url := fmt.Sprintf("https://api.vercel.com/v4/domains/%s/records", req.Domain)
if c.teamID != "" {
url += "?teamId=" + c.teamID
}
var response struct {
RecordID string `json:"uid"`
}
payloadJSON, err := json.Marshal(req)
if err != nil {
return nil, err
}
err = c.doRequest(clientRequest{
ctx: ctx,
method: http.MethodPost,
url: url,
body: string(payloadJSON),
}, &response, c.createLimiter)
if err != nil {
return nil, err
}
return &vercelClient.DNSRecord{ID: response.RecordID}, nil
}
// updateDNSRecordRequest embeds the official SDK request but adds HTTPS support
type updateDNSRecordRequest struct {
vercelClient.UpdateDNSRecordRequest
HTTPS *httpsRecord `json:"https,omitempty"`
}
// UpdateDNSRecord updates a DNS record.
func (c *vercelProvider) UpdateDNSRecord(ctx context.Context, recordID string, req updateDNSRecordRequest) (*vercelClient.DNSRecord, error) {
url := fmt.Sprintf("https://api.vercel.com/v4/domains/records/%s", recordID)
if c.teamID != "" {
url += "?teamId=" + c.teamID
}
payloadJSON, err := json.Marshal(req)
if err != nil {
return nil, err
}
var result vercelClient.DNSRecord
err = c.doRequest(clientRequest{
ctx: ctx,
method: http.MethodPatch,
url: url,
body: string(payloadJSON),
}, &result, c.updateLimiter)
return &result, err
}
// DeleteDNSRecord deletes a DNS record.
func (c *vercelProvider) DeleteDNSRecord(ctx context.Context, domain string, recordID string) error {
url := fmt.Sprintf("https://api.vercel.com/v2/domains/%s/records/%s", domain, recordID)
if c.teamID != "" {
url += "?teamId=" + c.teamID
}
return c.doRequest(clientRequest{
ctx: ctx,
method: http.MethodDelete,
url: url,
}, nil, c.deleteLimiter)
}

View file

@ -0,0 +1,73 @@
package vercel
import (
"errors"
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/rejectif"
)
// AuditRecords returns a list of errors corresponding to the records
// that aren't supported by this provider. If all records are
// supported, an empty list is returned.
func AuditRecords(records []*models.RecordConfig) []error {
a := rejectif.Auditor{}
// last verified 2025-11-22
// vercel does not support custom NS records at apex (domain root)
// vercel automatically manages apex NS records
// attempted to set one will result in "invalid_name - Cannot set NS records at the root level. Only subdomain NS records are supported"
a.Add("NS", rejectif.NsAtApex)
// last verified 2025-11-22
// bad_request - Invalid request: The specified value is not a fully qualified domain name.
a.Add("MX", rejectif.MxNull)
// last verified 2025-11-22
// bad_request - Invalid request: missing required property `value`.
a.Add("TXT", rejectif.TxtIsEmpty)
// last verified 2025-11-22
// bad_request - invalid_value - The specified value is not a fully qualified domain name.
a.Add("CAA", rejectif.CaaHasEmptyTarget)
// last verified 2025-11-22
// Vercel misidentified extra fields in CAA record `0 issue letsencrypt.org; validationmethods=dns-01; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234`
// as "cansignhttpexchanges", and add extra incorrect validation on the value
//
// The unit test for rejectifCaaTargetContainsUnsupportedFields is added via auditrecords_test.go
// A vendor-specific intergration test case is added to integration_test.go
//
// invalid_value - Unexpected "cansignhttpexchanges" value.
a.Add("CAA", rejectifCaaTargetContainsUnsupportedFields)
return a.Audit(records)
}
func rejectifCaaTargetContainsUnsupportedFields(rc *models.RecordConfig) error {
target := rc.GetTargetField()
if !strings.Contains(target, ";") {
return nil
}
parts := strings.Split(target, ";")
// The first part is the domain, which we only check length for now
if len(parts[0]) < 1 {
return errors.New("caa target domain is empty")
}
for _, part := range parts[1:] {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Check if the part starts with "cansignhttpexchanges"
// It can be just "cansignhttpexchanges" or "cansignhttpexchanges=..."
if part == "cansignhttpexchanges" || strings.HasPrefix(part, "cansignhttpexchanges=") {
continue
}
return fmt.Errorf("caa target contains unsupported field: %s", part)
}
return nil
}

View file

@ -0,0 +1,61 @@
package vercel
import (
"testing"
"github.com/StackExchange/dnscontrol/v4/models"
)
func TestCaaTargetContainsUnsupportedFields(t *testing.T) {
tests := []struct {
name string
target string
wantErr bool
}{
{
name: "simple domain",
target: "letsencrypt.org",
wantErr: false,
},
{
name: "with cansignhttpexchanges",
target: "digicert.com; cansignhttpexchanges=yes",
wantErr: false,
},
{
name: "with empty domain",
target: ";",
wantErr: true,
},
{
name: "with validationmethods",
target: "letsencrypt.org; validationmethods=dns-01",
wantErr: true,
},
{
name: "with accounturi",
target: "letsencrypt.org; accounturi=https://example.com",
wantErr: true,
},
{
name: "with multiple params including allowed",
target: "letsencrypt.org; cansignhttpexchanges; validationmethods=dns-01",
wantErr: true,
},
{
name: "with unknown param",
target: "letsencrypt.org; foo=bar",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rc := &models.RecordConfig{}
rc.SetTarget(tt.target)
if err := rejectifCaaTargetContainsUnsupportedFields(rc); (err != nil) != tt.wantErr {
t.Errorf("caaTargetContainsUnsupportedFields() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

298
providers/vercel/request.go Normal file
View file

@ -0,0 +1,298 @@
package vercel
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
vercelClient "github.com/vercel/terraform-provider-vercel/client"
)
type clientRequest struct {
ctx context.Context
method string
url string
body string
errorOnNoContent bool
}
func (cr *clientRequest) toHTTPRequest() (*http.Request, error) {
r, err := http.NewRequestWithContext(
cr.ctx,
cr.method,
cr.url,
strings.NewReader(cr.body),
)
if err != nil {
return nil, err
}
// Use a custom user agent for dnscontrol
r.Header.Set("User-Agent", "dnscontrol https://github.com/StackExchange/dnscontrol/pull/3542")
if cr.body != "" {
r.Header.Set("Content-Type", "application/json")
}
return r, nil
}
// doRequest is a helper function for consistently requesting data from vercel.
// It implements rate limiting and retries.
func (c *vercelProvider) doRequest(req clientRequest, v interface{}, rl *rateLimiter) error {
// Use a default http client with timeout
httpClient := &http.Client{
Timeout: 5 * 60 * time.Second,
}
if rl == nil {
panic("doRequest is expecting a rate limiter but got nil, please fire an issue and ping @SukkaW")
}
for {
r, err := req.toHTTPRequest()
if err != nil {
return err
}
r.Header.Add("Authorization", "Bearer "+c.apiToken)
rl.delayRequest()
resp, err := httpClient.Do(r)
if err != nil {
return fmt.Errorf("error doing http request: %w", err)
}
// Handle rate limiting and retries, 429 is handled here
retry, err := rl.handleResponse(resp)
if err != nil {
defer resp.Body.Close()
return err
}
if retry {
defer resp.Body.Close()
continue
}
// Process response
err = c.processResponse(resp, v, req.errorOnNoContent)
defer resp.Body.Close()
return err
}
}
func (c *vercelProvider) processResponse(resp *http.Response, v interface{}, errorOnNoContent bool) error {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %w", err)
}
if resp.StatusCode >= 300 {
var errorResponse vercelClient.APIError
if len(responseBody) == 0 {
errorResponse.StatusCode = resp.StatusCode
return errorResponse
}
// Try to unmarshal wrapped error first
err = json.Unmarshal(responseBody, &struct {
Error *vercelClient.APIError `json:"error"`
}{
Error: &errorResponse,
})
if err != nil {
// Try to unmarshal directly if it's not wrapped in "error"
if err2 := json.Unmarshal(responseBody, &errorResponse); err2 != nil {
return fmt.Errorf("error unmarshaling response for status code %d: %w", resp.StatusCode, err)
}
}
errorResponse.StatusCode = resp.StatusCode
errorResponse.RawMessage = responseBody
return errorResponse
}
if v == nil {
return nil
}
if errorOnNoContent && resp.StatusCode == 204 {
return vercelClient.APIError{
StatusCode: 204,
Code: "no_content",
Message: "No content",
}
}
// If we expect content but got none (and not 204), that might be an issue,
// but json.Unmarshal will just do nothing if empty, or error.
if len(responseBody) > 0 {
err = json.Unmarshal(responseBody, v)
if err != nil {
return fmt.Errorf("error unmarshaling response %s: %w", responseBody, err)
}
}
return nil
}
// rateLimiter handles Vercel's rate limits
type rateLimiter struct {
mu sync.Mutex
delay time.Duration
lastRequest time.Time
resetAt time.Time
defaultLimit int64
defaultWindow time.Duration
remaining int64 // Local tracking for operations without headers
}
func newRateLimiter(limit int64, window time.Duration) *rateLimiter {
return &rateLimiter{
defaultLimit: limit,
defaultWindow: window,
remaining: limit, // Start with full (safe) quota
resetAt: time.Now().Add(window),
}
}
func (rl *rateLimiter) delayRequest() {
rl.mu.Lock()
// Check if we need to reset local quota
if time.Now().After(rl.resetAt) {
rl.remaining = rl.defaultLimit
rl.resetAt = time.Now().Add(rl.defaultWindow)
}
// When not rate-limited, include network/server latency in delay.
next := rl.lastRequest.Add(rl.delay)
if next.After(rl.resetAt) {
// Do not stack delays past the reset point.
next = rl.resetAt
}
rl.lastRequest = next
rl.mu.Unlock()
wait := time.Until(next)
if wait > 0 {
time.Sleep(wait)
}
}
func (rl *rateLimiter) handleResponse(resp *http.Response) (bool, error) {
rl.mu.Lock()
defer rl.mu.Unlock()
// Decrement local remaining count
if rl.remaining > 0 {
rl.remaining--
}
if resp.StatusCode == http.StatusTooManyRequests {
printer.Printf("Rate-Limited. URL: %q, Headers: %v\n", resp.Request.URL, resp.Header)
// Check Retry-After header first
retryAfter, err := parseHeaderAsSeconds(resp.Header, "Retry-After", 0)
if err == nil && retryAfter > 0 {
rl.delay = retryAfter
rl.lastRequest = time.Now()
return true, nil
}
// Fallback to x-ratelimit-reset if Retry-After is missing/invalid
resetAt, err := parseHeaderAsEpoch(resp.Header, "x-ratelimit-reset")
if err == nil {
rl.delay = time.Until(resetAt)
if rl.delay < 0 {
rl.delay = time.Second // Minimum delay if reset is in past
}
rl.lastRequest = time.Now()
return true, nil
}
// Default fallback if no headers
rl.delay = 5 * time.Second
rl.lastRequest = time.Now()
return true, nil
}
// Parse standard rate limit headers to proactively delay
// Vercel headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset
// These headers are only present on Create and Update operations
limit, err := parseHeaderAsInt(resp.Header, "x-ratelimit-limit", -1)
if err != nil || limit == -1 {
// Update default limit if provided
// We don't update rl.defaultLimit permanently, but use it for calculation
limit = rl.defaultLimit
}
remaining, err := parseHeaderAsInt(resp.Header, "x-ratelimit-remaining", -1)
if err != nil || remaining == -1 {
// Use local tracking
remaining = rl.remaining
} else {
// Sync local tracking with server
rl.remaining = remaining
}
resetAt, err := parseHeaderAsEpoch(resp.Header, "x-ratelimit-reset")
if err == nil {
rl.resetAt = resetAt
} else {
// Use local resetAt
resetAt = rl.resetAt
}
// Apply safety factor
safeRemaining := remaining - 2
if safeRemaining <= 0 {
// Quota exhausted (safely). Wait until quota resets.
rl.delay = time.Until(resetAt)
} else if safeRemaining > limit/2 {
// Burst through half of the safe quota
rl.delay = 0
} else {
// Spread requests evenly
window := time.Until(resetAt)
if window > 0 {
rl.delay = window / time.Duration(safeRemaining+1)
} else {
rl.delay = 0
}
}
return false, nil
}
func parseHeaderAsInt(headers http.Header, headerName string, fallback int64) (int64, error) {
v := headers.Get(headerName)
if v == "" {
return fallback, nil
}
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fallback, err
}
return i, nil
}
func parseHeaderAsSeconds(header http.Header, headerName string, fallback time.Duration) (time.Duration, error) {
val, err := parseHeaderAsInt(header, headerName, -1)
if err != nil || val == -1 {
return fallback, err
}
return time.Duration(val) * time.Second, nil
}
func parseHeaderAsEpoch(header http.Header, headerName string) (time.Time, error) {
val, err := parseHeaderAsInt(header, headerName, -1)
if err != nil || val == -1 {
return time.Time{}, fmt.Errorf("header %s not found or invalid", headerName)
}
return time.Unix(val, 0), nil
}

View file

@ -0,0 +1,397 @@
package vercel
/*
Vercel DNS provider (vercel.com)
Info required in `creds.json`:
- team_id
- api_token
*/
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/providers"
"github.com/miekg/dns"
vercelClient "github.com/vercel/terraform-provider-vercel/client"
)
var features = providers.DocumentationNotes{
// The default for unlisted capabilities is 'Cannot'.
// See providers/capabilities.go for the entire list of capabilities.
providers.CanAutoDNSSEC: providers.Cannot(),
providers.CanGetZones: providers.Cannot(),
providers.CanConcur: providers.Unimplemented(),
providers.CanUseDNAME: providers.Cannot(),
providers.CanUseAlias: providers.Can(),
providers.CanUseCAA: providers.Can(),
providers.CanUseDHCID: providers.Cannot(),
providers.CanUseDS: providers.Cannot(),
providers.CanUseDSForChildren: providers.Cannot(),
providers.CanUseLOC: providers.Cannot(),
providers.CanUseNAPTR: providers.Cannot(),
providers.CanUsePTR: providers.Cannot(),
providers.CanUseSOA: providers.Cannot(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSVCB: providers.Cannot(),
providers.CanUseHTTPS: providers.Can(),
providers.CanUseSSHFP: providers.Cannot(),
providers.CanUseTLSA: providers.Cannot(),
providers.CanUseDNSKEY: providers.Cannot(),
providers.DocCreateDomains: providers.Cannot("Vercel requires a domain to be associated with a project before it can be added and managed"),
providers.DocDualHost: providers.Cannot("Vercel does not allow sufficient control over the apex NS records"),
providers.DocOfficiallySupported: providers.Cannot(),
}
// vercelProvider stores login credentials and represents and API connection
type vercelProvider struct {
client vercelClient.Client
apiToken string
teamID string
createLimiter *rateLimiter
updateLimiter *rateLimiter
deleteLimiter *rateLimiter
listLimiter *rateLimiter
}
// uint16Zero converts value to uint16 or returns 0, use wisely
//
// Vercel's Go SDK implies int64 for almost everything, but since Vercel doesn't actually
// implement their own NS and instead uses NS1 / Constellix (previously), we'd assume if
// TTL and Priority are int64, they are in fact uint16 and otherwise be rejected by upstream
// providers. Under this assumption, we'd convert int64 to uint16 as wells.
func uint16Zero(value interface{}) uint16 {
switch v := value.(type) {
case float64:
return uint16(v)
case uint16:
return v
case int64:
return uint16(v)
case nil:
}
return 0
}
func init() {
const providerName = "VERCEL"
const providerMaintainer = "@SukkaW"
fns := providers.DspFuncs{
Initializer: newProvider,
RecordAuditor: AuditRecords,
}
providers.RegisterDomainServiceProviderType(providerName, fns, providers.CanUseSRV, features)
providers.RegisterMaintainer(providerName, providerMaintainer)
}
func newProvider(creds map[string]string, meta json.RawMessage) (providers.DNSServiceProvider, error) {
if creds["api_token"] == "" {
return nil, errors.New("api_token required for VERCEL")
}
c := vercelClient.New(
creds["api_token"],
)
ctx := context.Background()
team, err := c.Team(ctx, creds["team_id"])
if err != nil {
return nil, err
}
c = c.WithTeam(team)
return &vercelProvider{
client: *c,
apiToken: creds["api_token"],
teamID: creds["team_id"],
// rate limiters
createLimiter: newRateLimiter(100, time.Hour),
updateLimiter: newRateLimiter(50, time.Minute),
deleteLimiter: newRateLimiter(50, time.Minute),
listLimiter: newRateLimiter(50, time.Minute),
}, nil
}
// GetNameservers returns empty array.
// Vercel doesn't permit apex NS records. Vercel's API doesn't even include apex NS records in their API response
// To prevent DNSControl from trying to create default NS records, let' return an empty array here, just like
// exoscale provider and gandi v5 provider
func (c *vercelProvider) GetNameservers(_ string) ([]*models.Nameserver, error) {
return []*models.Nameserver{}, nil
}
func (c *vercelProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
var zoneRecords []*models.RecordConfig
records, err := c.ListDNSRecords(context.Background(), domain)
if err != nil {
return nil, err
}
for _, r := range records {
// Vercel has some system-created records that can't be deleted/modified. They can be overridden
// by creating new records (where the DNS will prefer your record), but those system records are
// still included in the API response.
//
// Those records will have their "creator" being "system", some of them even has a comment field
// "Vercel automatically manages this record. It may change without notice".
//
// Per https://github.com/StackExchange/dnscontrol/pull/3542#issuecomment-3560041419, let's
// pretend those records don't exist, and diff2.ByRecord() will not affect these existing records.
if r.Creator == "system" {
continue
}
rc := &models.RecordConfig{
TTL: uint32(r.TTL),
Original: r,
}
name := r.Name
if name == "@" {
name = ""
}
rc.SetLabel(name, domain)
if r.Type == "CNAME" || r.Type == "MX" {
r.Value = dns.CanonicalName(r.Value)
}
switch rtype := r.RecordType; rtype {
case "MX":
if err := rc.SetTargetMX(uint16Zero(r.MXPriority), r.Value); err != nil {
return nil, fmt.Errorf("unparsable MX record: %w", err)
}
case "SRV":
// Vercel's API doesn't always return SRV as an SRV object.
// It might return priority in the json field, and the srv as a big string `[weight] [port] [domain]` in json 'value' field.
// We have to create our own string before passing in.
// Fallback to parsing from string if SRV object is missing
// r.Value is "weight port target", we need "priority weight port target"
if err := rc.PopulateFromString(
rtype,
fmt.Sprintf("%d %s", uint16Zero(r.Priority), r.Value),
domain,
); err != nil {
return nil, fmt.Errorf("unparsable SRV record from value: %w", err)
}
case "HTTPS":
// Vercel returns priority in a separate field, and value contains "target params".
// We need to combine them for PopulateFromString.
if err := rc.PopulateFromString(
rtype,
fmt.Sprintf("%d %s", uint16Zero(r.Priority), r.Value),
domain,
); err != nil {
return nil, fmt.Errorf("unparsable HTTPS record: %w", err)
}
case "TXT":
err := rc.SetTargetTXT(r.Value)
if err != nil {
return nil, fmt.Errorf("unparsable TXT record: %w", err)
}
default:
if err := rc.PopulateFromString(rtype, r.Value, domain); err != nil {
return nil, fmt.Errorf("unparsable record received from vercel: %w", err)
}
}
zoneRecords = append(zoneRecords, rc)
}
return zoneRecords, nil
}
func (c *vercelProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, records models.Records) ([]*models.Correction, int, error) {
// Vercel is a "ByRecord" API.
// Vercel enforces a minimum TTL of 60 seconds
for _, record := range dc.Records {
record.TTL = max(record.TTL, 60)
}
instructions, actualChangeCount, err := diff2.ByRecord(records, dc, nil)
if err != nil {
return nil, 0, err
}
var corrections []*models.Correction
for _, inst := range instructions {
switch inst.Type {
case diff2.REPORT:
corrections = append(corrections, &models.Correction{
Msg: inst.MsgsJoined,
})
case diff2.CREATE:
corrections = append(corrections, c.mkCreateCorrection(dc.Name, inst.New[0], inst.Msgs[0]))
case diff2.CHANGE:
corrections = append(corrections, c.mkChangeCorrection(dc.Name, inst.Old[0], inst.New[0], inst.Msgs[0]))
case diff2.DELETE:
corrections = append(corrections, c.mkDeleteCorrection(dc.Name, inst.Old[0], inst.Msgs[0]))
default:
panic(fmt.Sprintf("unhandled inst.Type %s", inst.Type))
}
}
return corrections, actualChangeCount, nil
}
func (c *vercelProvider) mkCreateCorrection(domain string, newRec *models.RecordConfig, msg string) *models.Correction {
return &models.Correction{
Msg: msg,
F: func() error {
ctx := context.Background()
req, err := toVercelCreateRequest(domain, newRec)
if err != nil {
return err
}
_, err = c.CreateDNSRecord(ctx, req)
return err
},
}
}
func (c *vercelProvider) mkChangeCorrection(domain string, oldRec, newRec *models.RecordConfig, msg string) *models.Correction {
return &models.Correction{
Msg: msg,
F: func() error {
ctx := context.Background()
existingID := oldRec.Original.(DNSRecord).ID
// UpdateDNSRecord doesn't support type changes
// If record type changed, delete and re-create
if oldRec.Type != newRec.Type {
// Delete old record
if err := c.DeleteDNSRecord(ctx, domain, existingID); err != nil {
return err
}
// re-create new record.
// luckily, delete and create use different rate limit timers
// thus we are most likely can go through both.
req, err := toVercelCreateRequest(domain, newRec)
if err != nil {
return err
}
_, err = c.CreateDNSRecord(ctx, req)
return err
}
req, err := toVercelUpdateRequest(newRec)
if err != nil {
return err
}
_, err = c.UpdateDNSRecord(ctx, existingID, req)
return err
},
}
}
func (c *vercelProvider) mkDeleteCorrection(domain string, oldRec *models.RecordConfig, msg string) *models.Correction {
return &models.Correction{
Msg: msg,
F: func() error {
ctx := context.Background()
existingID := oldRec.Original.(DNSRecord).ID
return c.DeleteDNSRecord(ctx, domain, existingID)
},
}
}
// toVercelCreateRequest converts a RecordConfig to a Vercel CreateDNSRecordRequest.
func toVercelCreateRequest(domain string, rc *models.RecordConfig) (createDNSRecordRequest, error) {
req := createDNSRecordRequest{}
req.Domain = domain
name := rc.GetLabel()
if name == "@" {
name = ""
}
req.Name = name
req.Type = rc.Type
req.Value = rc.GetTargetField()
req.TTL = int64(rc.TTL)
req.Comment = ""
switch rc.Type {
case "MX":
req.MXPriority = int64(rc.MxPreference)
case "SRV":
req.SRV = &vercelClient.SRV{
Priority: int64(rc.SrvPriority),
Weight: int64(rc.SrvWeight),
Port: int64(rc.SrvPort),
Target: rc.GetTargetField(),
}
req.Value = "" // SRV uses the SRV struct, not Value
case "TXT":
req.Value = rc.GetTargetTXTJoined()
case "HTTPS":
req.HTTPS = &httpsRecord{
Priority: int64(rc.SvcPriority),
Target: rc.GetTargetField(),
Params: rc.SvcParams,
}
case "CAA":
req.Value = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
}
return req, nil
}
// toVercelUpdateRequest converts a RecordConfig to a Vercel UpdateDNSRecordRequest.
func toVercelUpdateRequest(rc *models.RecordConfig) (updateDNSRecordRequest, error) {
req := updateDNSRecordRequest{}
name := rc.GetLabel()
if name == "@" {
name = ""
}
req.Name = &name
value := rc.GetTargetField()
req.Value = &value
req.TTL = ptrInt64(int64(rc.TTL))
req.Comment = ""
switch rc.Type {
case "MX":
req.MXPriority = ptrInt64(int64(rc.MxPreference))
case "SRV":
req.SRV = &vercelClient.SRVUpdate{
Priority: ptrInt64(int64(rc.SrvPriority)),
Weight: ptrInt64(int64(rc.SrvWeight)),
Port: ptrInt64(int64(rc.SrvPort)),
Target: &value,
}
req.Value = nil // SRV uses the SRV struct, not Value
case "TXT":
txtValue := rc.GetTargetTXTJoined()
req.Value = &txtValue
case "HTTPS":
req.HTTPS = &httpsRecord{
Priority: int64(rc.SvcPriority),
Target: rc.GetTargetField(),
Params: rc.SvcParams,
}
case "CAA":
value := fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
req.Value = &value
}
return req, nil
}
// ptrInt64 returns a pointer to an int64
func ptrInt64(v int64) *int64 {
return &v
}