mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-09 13:46:07 +08:00
Merge branch 'main' into branch_allrecs
This commit is contained in:
commit
e42dbcda57
23 changed files with 1344 additions and 65 deletions
|
|
@ -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
1
OWNERS
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 |
BIN
documentation/assets/providers/vercel/vercel-team-id-slug.png
Normal file
BIN
documentation/assets/providers/vercel/vercel-team-id-slug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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"?
|
||||||
|
|
|
||||||
144
documentation/provider/vercel.md
Normal file
144
documentation/provider/vercel.md
Normal 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.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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
3
go.mod
|
|
@ -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
11
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
171
providers/vercel/api.go
Normal 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)
|
||||||
|
}
|
||||||
73
providers/vercel/auditrecords.go
Normal file
73
providers/vercel/auditrecords.go
Normal 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
|
||||||
|
}
|
||||||
61
providers/vercel/auditrecords_test.go
Normal file
61
providers/vercel/auditrecords_test.go
Normal 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
298
providers/vercel/request.go
Normal 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
|
||||||
|
}
|
||||||
397
providers/vercel/vercelProvider.go
Normal file
397
providers/vercel/vercelProvider.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue