From daf5a7a501d339abc0511e2bea1a60381f6e9d03 Mon Sep 17 00:00:00 2001 From: Sukka Date: Mon, 1 Dec 2025 21:41:56 +0800 Subject: [PATCH 1/4] VERCEL: Implement Vercel DNS Provider (#3379) (#3542) Fixes https://github.com/StackExchange/dnscontrol/issues/3379 Thanks to @SukkaW for adding this provider! Even though you claimed to be "not familiar with Go at all" the new code looks excellent! Great job! --- .goreleaser.yml | 2 +- OWNERS | 1 + README.md | 1 + documentation/SUMMARY.md | 1 + .../vercel/vercel-account-switcher.png | Bin 0 -> 25255 bytes .../providers/vercel/vercel-team-id-slug.png | Bin 0 -> 27809 bytes documentation/provider/index.md | 8 +- documentation/provider/vercel.md | 144 +++++++ go.mod | 3 + go.sum | 11 + integrationTest/integration_test.go | 17 + integrationTest/profiles.json | 6 + providers/_all/all.go | 1 + providers/vercel/api.go | 171 ++++++++ providers/vercel/auditrecords.go | 73 ++++ providers/vercel/auditrecords_test.go | 61 +++ providers/vercel/request.go | 298 +++++++++++++ providers/vercel/vercelProvider.go | 397 ++++++++++++++++++ 18 files changed, 1193 insertions(+), 2 deletions(-) create mode 100644 documentation/assets/providers/vercel/vercel-account-switcher.png create mode 100644 documentation/assets/providers/vercel/vercel-team-id-slug.png create mode 100644 documentation/provider/vercel.md create mode 100644 providers/vercel/api.go create mode 100644 providers/vercel/auditrecords.go create mode 100644 providers/vercel/auditrecords_test.go create mode 100644 providers/vercel/request.go create mode 100644 providers/vercel/vercelProvider.go diff --git a/.goreleaser.yml b/.goreleaser.yml index c2938e051..dad3a4fe4 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -39,7 +39,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - 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 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/OWNERS b/OWNERS index 3cc1ee6fe..b3b6ab09e 100644 --- a/OWNERS +++ b/OWNERS @@ -53,4 +53,5 @@ providers/rwth @mistererwin providers/sakuracloud @ttkzw # providers/softlayer NEEDS VOLUNTEER providers/transip @blackshadev +providers/vercel @SukkaW providers/vultr @pgaskin diff --git a/README.md b/README.md index 8e0b7b457..6a8985375 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Currently supported DNS providers: - Sakura Cloud - SoftLayer - TransIP +- Vercel - Vultr Currently supported Domain Registrars: diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index d7d5dca11..157324c37 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -166,6 +166,7 @@ * [Sakura Cloud](provider/sakuracloud.md) * [SoftLayer DNS](provider/softlayer.md) * [TransIP](provider/transip.md) +* [Vercel](provider/vercel.md) * [Vultr](provider/vultr.md) ## Commands diff --git a/documentation/assets/providers/vercel/vercel-account-switcher.png b/documentation/assets/providers/vercel/vercel-account-switcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ae94ebcb4649c3ed7478f87c69db34955dce820a GIT binary patch literal 25255 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�qh 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 u^7rB&fGA8T$nZoChk8l*^PXg8vIln4S^< literal 0 HcmV?d00001 diff --git a/documentation/assets/providers/vercel/vercel-team-id-slug.png b/documentation/assets/providers/vercel/vercel-team-id-slug.png new file mode 100644 index 0000000000000000000000000000000000000000..8bf200b2beb468b551024098270fe91074adf9ad GIT binary patch literal 27809 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 +MLKbWlm`t;Fe5;40@ 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 @@ -428,6 +434,7 @@ Providers in this category and their maintainers are: |[`SAKURACLOUD`](sakuracloud.md)|@ttkzw| |[`SOFTLAYER`](softlayer.md)|@jamielennox| |[`TRANSIP`](transip.md)|@blackshadev| +|[`VERCEL`](vercel.md)|@SukkaW| |[`VULTR`](vultr.md)|@pgaskin| ### 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) * [SynergyWholesale](https://github.com/StackExchange/dnscontrol/issues/1605) (#1605) * [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) #### Q: Why are the above GitHub issues marked "closed"? diff --git a/documentation/provider/vercel.md b/documentation/provider/vercel.md new file mode 100644 index 000000000..e82d36626 --- /dev/null +++ b/documentation/provider/vercel.md @@ -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. diff --git a/go.mod b/go.mod index 6b2894dd6..2206a070a 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( github.com/nicholas-fedor/shoutrrr v0.12.0 github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 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 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/text v0.31.0 @@ -122,7 +123,9 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gopherjs/gopherjs v1.17.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/terraform-plugin-log v0.9.0 // 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/mattn/go-colorable v0.1.14 // indirect diff --git a/go.sum b/go.sum index a508646c3..22192c00d 100644 --- a/go.sum +++ b/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/go.mod h1:sX5TZ4HrMLYSzErWeckIHRZWgZj9PbKMAEKOVLFWtfM= 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/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 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/go.mod h1:zv7x2kM7xyJ5mW/+y4HbfxQYhk8TE57ypTa1hofsYdw= 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/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.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.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 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-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/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-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/go.mod h1:zFe/i17IP6/NGFkWGGsPL0t7VrL6u14HU8Hr06X4Qmg= 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.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.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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/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/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/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= 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-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-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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index ea2ec93e2..b45cb07c6 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -411,6 +411,7 @@ func makeTests() []*TestGroup { "NETCUP", // NS records not currently supported. "SAKURACLOUD", // Silently ignores requests to remove NS at @. "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("Dual NS at apex", ns("@", "ns2.foo.com."), ns("@", "ns1.foo.com.")), @@ -621,6 +622,7 @@ func makeTests() []*TestGroup { // Notes: // - Gandi: page size is 100, therefore we test with 99, 100, and 101 // - DIGITALOCEAN: page size is 100 (default: 20) + // - VERCEL: up to 100 per pages not( "AZURE_DNS", // Removed because it is too slow "CLOUDFLAREAPI", // Infinite pagesize but due to slow speed, skipping. @@ -637,6 +639,7 @@ func makeTests() []*TestGroup { "TRANSIP", // Doesn't page. Works fine. Due to the slow API we skip. "CNR", // Test beaks limits. "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("100 records", manyA("pager101-rec%04d", "1.2.3.4", 100)...), @@ -1344,6 +1347,20 @@ func makeTests() []*TestGroup { 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 // Narrative: You're basically done now. These remaining tests diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index 4d84672a8..481f6f947 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -340,6 +340,12 @@ "TYPE": "TRANSIP", "domain": "$TRANSIP_DOMAIN" }, + "VERCEL": { + "TYPE": "VERCEL", + "api_token": "$VERCEL_API_TOKEN", + "domain": "$VERCEL_DOMAIN", + "team_id": "$VERCEL_TEAM_ID" + }, "VULTR": { "TYPE": "VULTR", "domain": "$VULTR_DOMAIN", diff --git a/providers/_all/all.go b/providers/_all/all.go index 74355f5f8..5e904795e 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -58,5 +58,6 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/sakuracloud" _ "github.com/StackExchange/dnscontrol/v4/providers/softlayer" _ "github.com/StackExchange/dnscontrol/v4/providers/transip" + _ "github.com/StackExchange/dnscontrol/v4/providers/vercel" _ "github.com/StackExchange/dnscontrol/v4/providers/vultr" ) diff --git a/providers/vercel/api.go b/providers/vercel/api.go new file mode 100644 index 000000000..46ab01e8b --- /dev/null +++ b/providers/vercel/api.go @@ -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) +} diff --git a/providers/vercel/auditrecords.go b/providers/vercel/auditrecords.go new file mode 100644 index 000000000..4beeae303 --- /dev/null +++ b/providers/vercel/auditrecords.go @@ -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 +} diff --git a/providers/vercel/auditrecords_test.go b/providers/vercel/auditrecords_test.go new file mode 100644 index 000000000..e9c101ac1 --- /dev/null +++ b/providers/vercel/auditrecords_test.go @@ -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) + } + }) + } +} diff --git a/providers/vercel/request.go b/providers/vercel/request.go new file mode 100644 index 000000000..1dcab2dcd --- /dev/null +++ b/providers/vercel/request.go @@ -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 +} diff --git a/providers/vercel/vercelProvider.go b/providers/vercel/vercelProvider.go new file mode 100644 index 000000000..fd1c7be41 --- /dev/null +++ b/providers/vercel/vercelProvider.go @@ -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 +} From c073f2e6543cdfdd388e681ac0d927f85f1d322f Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 1 Dec 2025 15:08:43 +0100 Subject: [PATCH 2/4] HETZNER: gracefully handle FQDN labels when listing records (#3859) - Closes https://github.com/StackExchange/dnscontrol/issues/3853 This PR is gracefully handling FQDN labels when listing records from the Hetzner DNS Control api. These records can be created via other tools or the browser UI. Testing: ```diff diff --git a/providers/hetzner/types.go b/providers/hetzner/types.go index 964f1b7b..3429acc2 100644 --- a/providers/hetzner/types.go +++ b/providers/hetzner/types.go @@ -3,2 +3,3 @@ package hetzner import ( + "fmt" "strings" @@ -63,3 +64,3 @@ func fromRecordConfig(in *models.RecordConfig, zone zone) record { r := record{ - Name: in.GetLabel(), + Name: in.GetLabelFQDN() + ".", Type: in.Type, @@ -69,2 +70,3 @@ func fromRecordConfig(in *models.RecordConfig, zone zone) record { } + fmt.Printf("CREATE: %q\n", r.Name) @@ -93,2 +95,3 @@ func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) { } + fmt.Printf("LISTING: %q\n", r.Name) if strings.HasSuffix(r.Name, "."+domain+".") { ``` Config: ```js var REG_NONE = NewRegistrar('none') var DSP = NewDnsProvider("HETZNER") D('testing1.dev', REG_NONE, DnsProvider(DSP), A('@', '127.0.0.1'), A('foo', '127.0.0.1') ) ``` First push: ``` Waiting for concurrent gathering(s) to complete...LISTING: "@" LISTING: "@" LISTING: "@" LISTING: "@" CREATE: "foo.testing1.dev." DONE ******************** Domain: testing1.dev 1 correction (HETZNER) #1: Batch creation of records: + CREATE A foo.testing1.dev 127.0.0.1 ttl=300 SUCCESS! Done. 1 corrections. ``` Second push (no-op): ``` Waiting for concurrent gathering(s) to complete...LISTING: "@" LISTING: "@" LISTING: "@" LISTING: "@" LISTING: "foo.testing1.dev." DONE ******************** Domain: testing1.dev Done. 0 corrections. ``` DNS query: ``` $ dig foo.testing1.dev. @helium.ns.hetzner.de. ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 53563 foo.testing1.dev. 300 IN A 127.0.0.1 ``` Additional testing: - update/delete `foo` when record `foo.testing1.dev.` exists, works - creating `foo.testing1.dev` is treated as `foo.testing1.dev.testing1.dev.` in the API, hence the specific suffix check for `DOMAIN` - Test with HETZNER_V2, rejects records with FQDN ``` FAILURE! has dot (.) suffix (invalid_input, 50f9cf872ed8f1f808fd33c25cf88a81) ``` --- providers/hetzner/types.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/providers/hetzner/types.go b/providers/hetzner/types.go index 40b17ad43..964f1b7bb 100644 --- a/providers/hetzner/types.go +++ b/providers/hetzner/types.go @@ -1,6 +1,8 @@ package hetzner import ( + "strings" + "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/txtutil" ) @@ -89,7 +91,12 @@ func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) { TTL: *r.TTL, 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. // NOTE: The actual DNS answer does not contain the space. From ec9a9e23af1ba6b878f6e60cba126750d462325d Mon Sep 17 00:00:00 2001 From: Kevin Ji <1146876+kevinji@users.noreply.github.com> Date: Mon, 1 Dec 2025 06:12:10 -0800 Subject: [PATCH 3/4] CLOUDFLARE: Add LOC support (#3857) Fixes #2798. I tested this locally and it seems to update the `LOC` record correctly. --- documentation/provider/index.md | 2 +- models/t_loc.go | 71 ++++++++++++++++++---- providers/cloudflare/cloudflareProvider.go | 58 +++++++++++------- providers/cloudflare/rest.go | 70 ++++++++++++++------- 4 files changed, 141 insertions(+), 60 deletions(-) diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 1feac91a5..4b81545c8 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -158,7 +158,7 @@ Jump to a table: | [`AZURE_PRIVATE_DNS`](azure_private_dns.md) | ❌ | ❔ | ❌ | ✅ | ❔ | | [`BIND`](bind.md) | ❔ | ✅ | ✅ | ✅ | ✅ | | [`BUNNY_DNS`](bunny_dns.md) | ✅ | ❔ | ❌ | ✅ | ❌ | -| [`CLOUDFLAREAPI`](cloudflareapi.md) | ✅ | ❔ | ❌ | ✅ | ❔ | +| [`CLOUDFLAREAPI`](cloudflareapi.md) | ✅ | ❔ | ✅ | ✅ | ❔ | | [`CLOUDNS`](cloudns.md) | ✅ | ✅ | ✅ | ✅ | ❔ | | [`CNR`](cnr.md) | ✅ | ❌ | ❌ | ✅ | ❌ | | [`DESEC`](desec.md) | ❔ | ❔ | ❔ | ✅ | ❔ | diff --git a/models/t_loc.go b/models/t_loc.go index a8f1a7596..9ba1f8360 100644 --- a/models/t_loc.go +++ b/models/t_loc.go @@ -99,26 +99,20 @@ func (rc *RecordConfig) calculateLOCFields(d1 uint8, m1 uint8, s1 float32, ns st ) error { // Crazy hairy shit happens here. // We already got the useful "string" version earlier. ¯\_(ツ)_/¯ code golf... - const LOCEquator uint64 = 0x80000000 // 1 << 31 // RFC 1876, Section 2. - const LOCPrimeMeridian uint64 = 0x80000000 // 1 << 31 // RFC 1876, Section 2. - 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)) + lat := uint32(d1)*dns.LOC_DEGREES + uint32(m1)*dns.LOC_HOURS + uint32(s1*1000) + lon := uint32(d2)*dns.LOC_DEGREES + uint32(m2)*dns.LOC_HOURS + uint32(s2*1000) if strings.ToUpper(ns) == "N" { - rc.LocLatitude = uint32(LOCEquator + lat) + rc.LocLatitude = dns.LOC_EQUATOR + lat } else { // "S" - rc.LocLatitude = uint32(LOCEquator - lat) + rc.LocLatitude = dns.LOC_EQUATOR - lat } if strings.ToUpper(ew) == "E" { - rc.LocLongitude = uint32(LOCPrimeMeridian + lon) + rc.LocLongitude = dns.LOC_PRIMEMERIDIAN + lon } else { // "W" - rc.LocLongitude = uint32(LOCPrimeMeridian - lon) + rc.LocLongitude = dns.LOC_PRIMEMERIDIAN - lon } // Altitude - altitude := (float64(al) + float64(LOCAltitudeBase)) * 100 + altitude := (float64(al) + dns.LOC_ALTITUDEBASE) * 100 clampedAltitude := math.Min(math.Max(0, altitude), float64(math.MaxUint32)) rc.LocAltitude = uint32(clampedAltitude) @@ -205,3 +199,54 @@ func getENotationInt(x float32) (uint8, error) { 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 +} diff --git a/providers/cloudflare/cloudflareProvider.go b/providers/cloudflare/cloudflareProvider.go index d3b4fd93c..cd2fbf9b9 100644 --- a/providers/cloudflare/cloudflareProvider.go +++ b/providers/cloudflare/cloudflareProvider.go @@ -53,7 +53,7 @@ var features = providers.DocumentationNotes{ providers.CanUseDS: providers.Can(), providers.CanUseDSForChildren: providers.Can(), providers.CanUseHTTPS: providers.Can(), - providers.CanUseLOC: providers.Cannot(), + providers.CanUseLOC: providers.Can(), providers.CanUseNAPTR: providers.Can(), providers.CanUsePTR: providers.Can(), providers.CanUseSRV: providers.Can(), @@ -708,28 +708,40 @@ func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNS // Used on the "existing" records. type cfRecData struct { - Name string `json:"name"` - Target cfTarget `json:"target"` - Service string `json:"service"` // SRV - Proto string `json:"proto"` // SRV - Priority uint16 `json:"priority"` // SRV - Weight uint16 `json:"weight"` // SRV - Port uint16 `json:"port"` // SRV - Tag string `json:"tag"` // CAA - Flags uint16 `json:"flags"` // CAA/DNSKEY - Value string `json:"value"` // CAA - Usage uint8 `json:"usage"` // TLSA - Selector uint8 `json:"selector"` // TLSA - MatchingType uint8 `json:"matching_type"` // TLSA - Certificate string `json:"certificate"` // TLSA - Algorithm uint8 `json:"algorithm"` // SSHFP/DNSKEY/DS - HashType uint8 `json:"type"` // SSHFP - Fingerprint string `json:"fingerprint"` // SSHFP - Protocol uint8 `json:"protocol"` // DNSKEY - PublicKey string `json:"public_key"` // DNSKEY - KeyTag uint16 `json:"key_tag"` // DS - DigestType uint8 `json:"digest_type"` // DS - Digest string `json:"digest"` // DS + Name string `json:"name"` + Target cfTarget `json:"target"` + Service string `json:"service"` // SRV + Proto string `json:"proto"` // SRV + Priority uint16 `json:"priority"` // SRV + Weight uint16 `json:"weight"` // SRV + Port uint16 `json:"port"` // SRV + Tag string `json:"tag"` // CAA + Flags uint16 `json:"flags"` // CAA/DNSKEY + Value string `json:"value"` // CAA + Usage uint8 `json:"usage"` // TLSA + Selector uint8 `json:"selector"` // TLSA + MatchingType uint8 `json:"matching_type"` // TLSA + Certificate string `json:"certificate"` // TLSA + Algorithm uint8 `json:"algorithm"` // SSHFP/DNSKEY/DS + HashType uint8 `json:"type"` // SSHFP + Fingerprint string `json:"fingerprint"` // SSHFP + Protocol uint8 `json:"protocol"` // DNSKEY + PublicKey string `json:"public_key"` // DNSKEY + KeyTag uint16 `json:"key_tag"` // DS + DigestType uint8 `json:"digest_type"` // 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 diff --git a/providers/cloudflare/rest.go b/providers/cloudflare/rest.go index 9efd522ea..fdbc5d290 100644 --- a/providers/cloudflare/rest.go +++ b/providers/cloudflare/rest.go @@ -137,6 +137,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 { return &cfNaptrRecData{ Flags: rec.NaptrFlags, @@ -154,13 +174,12 @@ func (c *cloudflareProvider) createRecDiff2(rec *models.RecordConfig, domainID s content = rec.Metadata[metaOriginalIP] } prio := "" - if rec.Type == "MX" { + switch rec.Type { + case "MX": prio = fmt.Sprintf(" %d ", rec.MxPreference) - } - if rec.Type == "TXT" { + case "TXT": content = rec.GetTargetTXTJoined() - } - if rec.Type == "DS" { + case "DS": content = fmt.Sprintf("%d %d %d %s", rec.DsKeyTag, rec.DsAlgorithm, rec.DsDigestType, rec.DsDigest) } if msg == "" { @@ -179,28 +198,31 @@ func (c *cloudflareProvider) createRecDiff2(rec *models.RecordConfig, domainID s Content: content, Priority: &rec.MxPreference, } - if rec.Type == "SRV" { + switch rec.Type { + case "SRV": cf.Data = cfSrvData(rec) cf.Name = rec.GetLabelFQDN() - } else if rec.Type == "CAA" { + case "CAA": cf.Data = cfCaaData(rec) cf.Name = rec.GetLabelFQDN() cf.Content = "" - } else if rec.Type == "TLSA" { + case "TLSA": cf.Data = cfTlsaData(rec) cf.Name = rec.GetLabelFQDN() - } else if rec.Type == "SSHFP" { + case "SSHFP": cf.Data = cfSshfpData(rec) cf.Name = rec.GetLabelFQDN() - } else if rec.Type == "DNSKEY" { + case "DNSKEY": cf.Data = cfDnskeyData(rec) - } else if rec.Type == "DS" { + case "DS": cf.Data = cfDSData(rec) - } else if rec.Type == "NAPTR" { + case "NAPTR": cf.Data = cfNaptrData(rec) cf.Name = rec.GetLabelFQDN() - } else if rec.Type == "HTTPS" || rec.Type == "SVCB" { + case "HTTPS", "SVCB": cf.Data = cfSvcbData(rec) + case "LOC": + cf.Data = cfLocData(rec) } resp, err := c.cfClient.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), cf) if err != nil { @@ -232,33 +254,35 @@ func (c *cloudflareProvider) modifyRecord(domainID, recID string, proxied bool, Priority: &rec.MxPreference, TTL: int(rec.TTL), } - if rec.Type == "TXT" { + switch rec.Type { + case "TXT": r.Content = rec.GetTargetTXTJoined() - } - if rec.Type == "SRV" { + case "SRV": r.Data = cfSrvData(rec) r.Name = rec.GetLabelFQDN() - } else if rec.Type == "CAA" { + case "CAA": r.Data = cfCaaData(rec) r.Name = rec.GetLabelFQDN() r.Content = "" - } else if rec.Type == "TLSA" { + case "TLSA": r.Data = cfTlsaData(rec) r.Name = rec.GetLabelFQDN() - } else if rec.Type == "SSHFP" { + case "SSHFP": r.Data = cfSshfpData(rec) r.Name = rec.GetLabelFQDN() - } else if rec.Type == "DNSKEY" { + case "DNSKEY": r.Data = cfDnskeyData(rec) r.Content = "" - } else if rec.Type == "DS" { + case "DS": r.Data = cfDSData(rec) r.Content = "" - } else if rec.Type == "NAPTR" { + case "NAPTR": r.Data = cfNaptrData(rec) r.Name = rec.GetLabelFQDN() - } else if rec.Type == "HTTPS" || rec.Type == "SVCB" { + case "HTTPS", "SVCB": r.Data = cfSvcbData(rec) + case "LOC": + r.Data = cfLocData(rec) } _, err := c.cfClient.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), r) return err From 6e42ccfb3115034a4a82ca676e1de6c957b84358 Mon Sep 17 00:00:00 2001 From: Patrik Kernstock Date: Mon, 1 Dec 2025 15:13:06 +0100 Subject: [PATCH 4/4] INWX: Enable concurrency support (#3856) Tested dnscontrol with `CanConcur()` enabled and seems to work fine. Read #2873 to see what to do, and hope below is the right way to test. ```text $ go build -race -o dnscontrol-race $ ./dnscontrol-race version v4.27.2-0.20251127184623-cf6b870052c0+dirty $ dnscontrol-race preview CONCURRENTLY checking for 16 zone(s) SERIALLY checking for 6 zone(s) Serially checking for zone: "domainX.tld" Serially checking for zone: "domainX.tld" Serially checking for zone: "domainX.tld" Serially checking for zone: "domainX.tld" Serially checking for zone: "domainX.tld" Serially checking for zone: "domainX.tld" Waiting for concurrent checking(s) to complete...DONE CONCURRENTLY gathering records of 16 zone(s) SERIALLY gathering records of 6 zone(s) Serially Gathering: "domainX.tld" Serially Gathering: "domainX.tld" Serially Gathering: "domainX.tld" Serially Gathering: "domainX.tld" Serially Gathering: "domainX.tld" Serially Gathering: "domainX.tld" Waiting for concurrent gathering(s) to complete...DONE ******************** Domain: domainX.tld INFO#1: 4 records not being deleted because of NO_PURGE: [...] ******************** Domain: domainX.tld ******************** Domain: domainX.tld INFO#1: 4 records not being deleted because of NO_PURGE: [...] ******************** Domain: domainX.tld ******************** Domain: domainX.tld ******************** Domain: domainX.tld 1 correction (PK-INWX) INFO#1: 1 records not being deleted because of IGNORE*(): [...] ******************** Domain: domainX.tld ******************** Domain: domainX.tld ******************** Domain: domainX.tld ******************** Domain: domainX.tld 30 corrections (PK-INWX) [...] ******************** Domain: domainX.tld ******************** Domain: domainX.tld 2 corrections (PK-INWX) [...] ******************** Domain: domainX.tld ******************** Domain: domainX.tld ******************** Domain: domainX.tld ******************** Domain: domainX.tld 2 corrections (PK-INWX) [...] ******************** Domain: domainX.tld ******************** Domain: domainX.tld Done. 37 corrections. ``` Unfortunately INWX sandbox is sporadically still broken so `go test` is of limited help: ```text $ go test -v -verbose -profile INWX === RUN TestDNSProviders Testing Profile="INWX" (TYPE="INWX") helpers_test.go:122: INWX: Unable to login --- FAIL: TestDNSProviders (30.03s) === RUN TestDualProviders Testing Profile="INWX" (TYPE="INWX") provider_test.go:50: Clearing everything provider_test.go:57: Adding test nameservers provider_test.go:44: #1: + CREATE dnscontrol-inwx.com NS ns1.example.com. ttl=300 provider_test.go:44: #2: + CREATE dnscontrol-inwx.com NS ns2.example.com. ttl=300 provider_test.go:60: Running again to ensure stability provider_test.go:76: Removing test nameservers provider_test.go:44: #1: - DELETE dnscontrol-inwx.com NS ns1.example.com. ttl=300 provider_test.go:44: #2: - DELETE dnscontrol-inwx.com NS ns2.example.com. ttl=300 --- PASS: TestDualProviders (2.44s) === RUN TestNameserverDots Testing Profile="INWX" (TYPE="INWX") === RUN TestNameserverDots/No_trailing_dot_in_nameserver --- PASS: TestNameserverDots (0.30s) --- PASS: TestNameserverDots/No_trailing_dot_in_nameserver (0.00s) === RUN TestDuplicateNameservers Testing Profile="INWX" (TYPE="INWX") provider_test.go:145: Skipping. Deduplication logic is not implemented for this provider. --- SKIP: TestDuplicateNameservers (0.35s) FAIL exit status 1 FAIL github.com/StackExchange/dnscontrol/v4/integrationTest 33.127s ``` --- documentation/provider/index.md | 2 +- providers/inwx/inwxProvider.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 4b81545c8..50c15679f 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -119,7 +119,7 @@ Jump to a table: | [`HOSTINGDE`](hostingde.md) | ❔ | ✅ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ | | [`INTERNETBS`](internetbs.md) | ❔ | ❔ | ❌ | ❔ | -| [`INWX`](inwx.md) | ❔ | ✅ | ✅ | ✅ | +| [`INWX`](inwx.md) | ✅ | ✅ | ✅ | ✅ | | [`JOKER`](joker.md) | ❌ | ❌ | ✅ | ✅ | | [`LINODE`](linode.md) | ❔ | ❌ | ❌ | ✅ | | [`LOOPIA`](loopia.md) | ❔ | ✅ | ❌ | ✅ | diff --git a/providers/inwx/inwxProvider.go b/providers/inwx/inwxProvider.go index ef5461eee..96cbbb47e 100644 --- a/providers/inwx/inwxProvider.go +++ b/providers/inwx/inwxProvider.go @@ -51,7 +51,7 @@ var features = providers.DocumentationNotes{ // See providers/capabilities.go for the entire list of capabilities. providers.CanAutoDNSSEC: providers.Can(), providers.CanGetZones: providers.Can(), - providers.CanConcur: providers.Unimplemented(), + providers.CanConcur: providers.Can(), providers.CanUseAlias: 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."),