diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5fa6b9502c..08d8ff6318 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -57,6 +57,7 @@ providers/route53 @tresni providers/rwth @mistererwin providers/sakuracloud @ttkzw # providers/softlayer NEEDS VOLUNTEER +providers/tencentdns @cylonchau providers/transip @blackshadev providers/unifi @zupolgec providers/vercel @SukkaW diff --git a/.github/labeler.yml b/.github/labeler.yml index c707248bc6..b2540f1699 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -175,6 +175,9 @@ provider-SAKURACLOUD: provider-SOFTLAYER: - changed-files: - any-glob-to-any-file: providers/softlayer/** +provider-TENCENTDNS: + - changed-files: + - any-glob-to-any-file: providers/tencentdns/** provider-TRANSIP: - changed-files: - any-glob-to-any-file: providers/transip/** diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index d913ccf726..6c7dd6096d 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -230,6 +230,8 @@ jobs: # VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }} + TENCENTDNS_SECRET_ID: ${{ secrets.TENCENTDNS_SECRET_ID }} + TENCENTDNS_SECRET_KEY: ${{ secrets.TENCENTDNS_SECRET_KEY }} concurrency: group: ${{ github.workflow }}-${{ matrix.provider }} diff --git a/.goreleaser.yml b/.goreleaser.yml index e528b817bb..257fc57da0 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -37,7 +37,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - title: 'Provider-specific changes:' - regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|azuredns|bind|bunny_dns|bunnydns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hexonet|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netbird|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|unifi|vercel|vultr).*:)+.*" + regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|azuredns|bind|bunny_dns|bunnydns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hexonet|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netbird|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|tencentdns|transip|unifi|vercel|vultr).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/README.md b/README.md index b15db696a4..a3e11c3894 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ DNSControl supports 62 DNS providers and registrars: | Windows Server DNS | [MikroTik RouterOS](https://docs.dnscontrol.org/provider/mikrotik) | [Mythic Beasts](https://docs.dnscontrol.org/provider/mythicbeasts) | [Name.com](https://docs.dnscontrol.org/provider/namedotcom)¹ | [Namecheap](https://docs.dnscontrol.org/provider/namecheap)¹ | | [Netcup](https://docs.dnscontrol.org/provider/netcup) | [Netlify](https://docs.dnscontrol.org/provider/netlify) | [NS1](https://docs.dnscontrol.org/provider/ns1) | [OpenSRS](https://docs.dnscontrol.org/provider/opensrs)² | [Oracle Cloud](https://docs.dnscontrol.org/provider/oracle) | | [OVH](https://docs.dnscontrol.org/provider/ovh)¹ | [Packetframe](https://docs.dnscontrol.org/provider/packetframe) | [Porkbun](https://docs.dnscontrol.org/provider/porkbun)¹ | [PowerDNS](https://docs.dnscontrol.org/provider/powerdns) | [Realtime Register](https://docs.dnscontrol.org/provider/realtimeregister)¹ | -| [RWTH DNS-Admin](https://docs.dnscontrol.org/provider/rwth) | [Sakura Cloud](https://docs.dnscontrol.org/provider/sakuracloud) | [SoftLayer](https://docs.dnscontrol.org/provider/softlayer) | [TransIP](https://docs.dnscontrol.org/provider/transip) | [UniFi Network](https://docs.dnscontrol.org/provider/unifi) | -| [Vercel](https://docs.dnscontrol.org/provider/vercel) | [Vultr](https://docs.dnscontrol.org/provider/vultr) | [Netbird](https://docs.dnscontrol.org/provider/netbird) | | | +| [RWTH DNS-Admin](https://docs.dnscontrol.org/provider/rwth) | [Sakura Cloud](https://docs.dnscontrol.org/provider/sakuracloud) | [SoftLayer](https://docs.dnscontrol.org/provider/softlayer) | [Tencent Cloud DNS](https://docs.dnscontrol.org/provider/tencentdns)¹ | [TransIP](https://docs.dnscontrol.org/provider/transip) | +| [UniFi Network](https://docs.dnscontrol.org/provider/unifi) | [Vercel](https://docs.dnscontrol.org/provider/vercel) | [Vultr](https://docs.dnscontrol.org/provider/vultr) | [Netbird](https://docs.dnscontrol.org/provider/netbird) | | ¹also supports registrar functions ²registrar only diff --git a/documentation/provider/index.md b/documentation/provider/index.md index b9736ba2e9..79056e6c01 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -81,6 +81,7 @@ Jump to a table: | [`RWTH`](rwth.md) | ❌ | ✅ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ✅ | ❌ | | [`SOFTLAYER`](softlayer.md) | ❌ | ✅ | ❌ | +| [`TENCENTDNS`](tencentdns.md) | ❌ | ✅ | ✅ | | [`TRANSIP`](transip.md) | ❌ | ✅ | ❌ | | [`UNIFI`](unifi.md) | ❌ | ✅ | ❌ | | [`VERCEL`](vercel.md) | ❌ | ✅ | ❌ | @@ -149,6 +150,7 @@ Jump to a table: | [`RWTH`](rwth.md) | ❔ | ❌ | ❌ | ✅ | | [`SAKURACLOUD`](sakuracloud.md) | ❔ | ❌ | ✅ | ✅ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | +| [`TENCENTDNS`](tencentdns.md) | ❔ | ✅ | ✅ | ✅ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ✅ | | [`UNIFI`](unifi.md) | ❌ | ❔ | ❌ | ❌ | | [`VERCEL`](vercel.md) | ❔ | ❌ | ❌ | ❌ | @@ -212,6 +214,7 @@ Jump to a table: | [`RWTH`](rwth.md) | ❌ | ❔ | ❌ | ✅ | ❔ | | [`SAKURACLOUD`](sakuracloud.md) | ✅ | ❌ | ❌ | ✅ | ❌ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | ❔ | +| [`TENCENTDNS`](tencentdns.md) | ✅ | ❔ | ❔ | ✅ | ❔ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ❌ | ❌ | | [`UNIFI`](unifi.md) | ❌ | ❔ | ❌ | ❌ | ❔ | | [`VERCEL`](vercel.md) | ✅ | ❌ | ❌ | ❌ | ❌ | @@ -274,6 +277,7 @@ Jump to a table: | [`RWTH`](rwth.md) | ❔ | ❌ | ✅ | ❔ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ✅ | ✅ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ✅ | ❔ | +| [`TENCENTDNS`](tencentdns.md) | ❔ | ❔ | ✅ | ❔ | | [`TRANSIP`](transip.md) | ❌ | ✅ | ✅ | ❌ | | [`UNIFI`](unifi.md) | ❔ | ❔ | ✅ | ❔ | | [`VERCEL`](vercel.md) | ❌ | ❌ | ✅ | ❌ | @@ -333,6 +337,7 @@ Jump to a table: | [`ROUTE53`](route53.md) | ✅ | ✅ | ❔ | ✅ | ✅ | | [`RWTH`](rwth.md) | ✅ | ❔ | ❔ | ✅ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ✅ | ✅ | ❔ | ❌ | ❌ | +| [`TENCENTDNS`](tencentdns.md) | ✅ | ❔ | ❔ | ❔ | ❔ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❔ | ✅ | ✅ | | [`UNIFI`](unifi.md) | ❌ | ❔ | ❔ | ❌ | ❌ | | [`VERCEL`](vercel.md) | ✅ | ✅ | ❔ | ❌ | ❌ | @@ -467,9 +472,10 @@ Providers in this category and their maintainers are: |[`REALTIMEREGISTER`](realtimeregister.md)|@PJEilers| |[`ROUTE53`](route53.md)|@tresni| |[`RWTH`](rwth.md)|@MisterErwin| -|[`SAKURACLOUD`](sakuracloud.md)|@ttkzw| -|[`SOFTLAYER`](softlayer.md)|@jamielennox| -|[`TRANSIP`](transip.md)|@blackshadev| +| [`SAKURACLOUD`](sakuracloud.md) | @ttkzw | +| [`SOFTLAYER`](softlayer.md) | @jamielennox | +| [`TENCENTDNS`](tencentdns.md) | @cylonchau | +| [`TRANSIP`](transip.md) | @blackshadev | |[`VERCEL`](vercel.md)|@SukkaW| |[`VULTR`](vultr.md)|@pgaskin| diff --git a/documentation/provider/tencentdns.md b/documentation/provider/tencentdns.md new file mode 100644 index 0000000000..990f89a48c --- /dev/null +++ b/documentation/provider/tencentdns.md @@ -0,0 +1,130 @@ +## Configuration + +{% hint style="info" %} +This provider is developed for the **Tencent Cloud API 3.0** platform. +{% endhint %} + +This provider is for [Tencent Cloud DNS](https://cloud.tencent.com/product/dns) (DNSPod). To use this provider, add an entry to `creds.json` with `TYPE` set to `TENCENTDNS` along with your [API secrets](https://console.intl.cloud.tencent.com/cam/capi). + +Example: + +{% code title="creds.json" %} +```json +{ + "tencentdns": { + "TYPE": "TENCENTDNS", + "secret_id": "YOUR_SECRET_ID", + "secret_key": "YOUR_SECRET_KEY", + "site": "cn | intl" + } +} +``` +{% endcode %} + +Optionally, you can specify a `region` (defaults to `"ap-guangzhou"`): + +{% code title="creds.json" %} +```json +{ + "tencentdns": { + "TYPE": "TENCENTDNS", + "secret_id": "YOUR_SECRET_ID", + "secret_key": "YOUR_SECRET_KEY", + "region": "ap-guangzhou", + "site": "intl" + } +} +``` +{% endcode %} + +Optionally, you can specify a `site` (defaults to `"cn"`). Use `"intl"` for Tencent Cloud International accounts: + +{% code title="creds.json" %} +```json +{ + "tencentdns": { + "TYPE": "TENCENTDNS", + "secret_id": "YOUR_SECRET_ID", + "secret_key": "YOUR_SECRET_KEY", + "site": "intl" + } +} +``` +{% endcode %} + +Valid `site` values are: + +- `cn`: Tencent Cloud mainland China APIs. +- `intl`: Tencent Cloud International APIs. + +The `site` setting affects both DNSPod DNS management and registrar nameserver updates. + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_TENCENT = NewRegistrar("tencentdns", "TENCENTDNS"); +var DSP_TENCENT = NewDnsProvider("tencentdns", "TENCENTDNS"); + +D("example.com", REG_TENCENT, DnsProvider(DSP_TENCENT), + A("@", "1.2.3.4"), + CNAME("www", "example.com."), + MX("@", 10, "mail.example.com."), + TXT("test", "hello world") +); +``` +{% endcode %} + +### Why use `ALIAS` for DNSPod + +DNSPod does not natively support the `ALIAS` record type. + +In this provider, `ALIAS("@")` is used only as a DNSControl-side representation of CNAME flattening at the zone apex (`@`). It does not mean DNSPod has a real ALIAS record type. + +We use `ALIAS("@")` because DNSControl treats `CNAME("@")` as invalid. In standard DNS, a CNAME record cannot be placed at the zone apex, because the apex already contains required records such as `SOA` and `NS`. + +For DNSPod, the provider maps `ALIAS("@")` to a CNAME record on `@` under the hood. The actual CNAME flattening behavior must still be configured manually in the DNSPod dashboard. + +#### Example: + +**Recommended** + +Use `ALIAS("@")` for apex CNAME flattening: + +```js +D("example.com", REG_NONE, DnsProvider(DNSPOD), + ALIAS("@", "target.example.net.") +); +``` +**Not recommended** + +Avoid writing CNAME("@") directly: + +```js +D("example.com", REG_NONE, DnsProvider(DNSPOD), + CNAME("@", "target.example.net.") +); +``` + +For compatibility, the DNSPod provider automatically converts apex CNAME("@") to ALIAS("@") internally. This allows DNSControl to treat it as an apex-flattening record instead of a standard apex CNAME. + +### Note + +DNSPod does not natively support the ALIAS record type. In this provider, ALIAS("@") is only a DNSControl-side representation of apex CNAME flattening. + +When pushed to DNSPod, it is stored as a CNAME record on @. + +Reference: https://docs.dnspod.com/dns/faq-dns-resolution/?lang=en + + +## Important Notes + +### Features + +- **MX Records**: Priority and target are handled automatically. +- **Registrar Support**: Supports updating authoritative nameservers for domains registered with Tencent Cloud. +- **Tencent Cloud Site**: Use `site: "intl"` for Tencent Cloud International site, use `site: "cn"` for Tencent Cloud China site. +- **Line Management**: All records are created on the "默认" (Default) line. +- **New Domains**: DNSControl will automatically create non-existent domains in your account. diff --git a/go.mod b/go.mod index 4f68151792..4cb00d1cda 100644 --- a/go.mod +++ b/go.mod @@ -100,6 +100,10 @@ require ( github.com/nicholas-fedor/shoutrrr v0.15.0 github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 github.com/oracle/oci-go-sdk/v65 v65.114.2 + github.com/tencentcloud/tencentcloud-sdk-go-intl-en v3.0.1406+incompatible + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.93 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.3.78 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/domain v1.3.66 github.com/urfave/cli/v3 v3.9.0 github.com/vercel/terraform-provider-vercel v1.14.1 github.com/vultr/govultr/v2 v2.17.2 diff --git a/go.sum b/go.sum index fcb02b5a8f..ab30e8950d 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tencentcloud/tencentcloud-sdk-go-intl-en v3.0.1406+incompatible h1:tGQadaNd/OjES75vfkiglLavxrKF0972AbJgSVQ1Cco= +github.com/tencentcloud/tencentcloud-sdk-go-intl-en v3.0.1406+incompatible/go.mod h1:72Wo6Gt6F8d8V+njrAmduVoT9QjPwCyXktpqCWr7PUc= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.66/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.78/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.93 h1:LItHgi4vvgkfLLFLhL8FL2yOxoquKMrhaWy70vewa2g= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.93/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.3.78 h1:Wr/AVJTVlCLG+FJnp2+xveQs9zUT8pB1kfTOZ0drhv8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.3.78/go.mod h1:cLF8HFXXVj5VmAL/yRn/TEnN14fyRDi1elg1hx4f7Es= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/domain v1.3.66 h1:cn5pZx45fXoqKIiNbNVzrVNfky16tPL2x1Fq6kBFlgc= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/domain v1.3.66/go.mod h1:+qAEFuRYl5CHjLOfS55Ce/9jEgX06R6TEWsdVVM60Nc= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/transip/gotransip/v6 v6.27.1 h1:J8DfGAxnFZxNYdIRj59D6uFm0FPOkx9tF1aCkGgXeR8= diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index d287befbf3..696304810b 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -358,6 +358,13 @@ "domain": "$SL_DOMAIN", "username": "$SL_USERNAME" }, + "TENCENTDNS": { + "TYPE": "TENCENTDNS", + "domain": "$TENCENTDNS_DOMAIN", + "secret_id": "$TENCENTDNS_SECRET_ID", + "secret_key": "$TENCENTDNS_SECRET_KEY", + "site": "$TENCENTDNS_SITE" + }, "TRANSIP": { "AccessToken": "$TRANSIP_ACCESS_TOKEN", "AccountName": "$TRANSIP_ACCOUNT_NAME", diff --git a/pkg/providers/_all/all.go b/pkg/providers/_all/all.go index 53e54366be..6af1df750f 100644 --- a/pkg/providers/_all/all.go +++ b/pkg/providers/_all/all.go @@ -62,6 +62,7 @@ import ( _ "github.com/DNSControl/dnscontrol/v4/providers/rwth" _ "github.com/DNSControl/dnscontrol/v4/providers/sakuracloud" _ "github.com/DNSControl/dnscontrol/v4/providers/softlayer" + _ "github.com/DNSControl/dnscontrol/v4/providers/tencentdns" _ "github.com/DNSControl/dnscontrol/v4/providers/transip" _ "github.com/DNSControl/dnscontrol/v4/providers/unifi" _ "github.com/DNSControl/dnscontrol/v4/providers/vercel" diff --git a/providers/tencentdns/api.go b/providers/tencentdns/api.go new file mode 100644 index 0000000000..ccd6dac9cd --- /dev/null +++ b/providers/tencentdns/api.go @@ -0,0 +1,326 @@ +package tencentdns + +import ( + "fmt" + "strings" + "time" + + intlcommon "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/common" + intlprofile "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/common/profile" + intldomain "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/domain/v20180808" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" + domain "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/domain/v20180808" +) + +const ( + domainBatchPollAttempts = 30 + domainBatchPollInterval = 2 * time.Second + + intlDNSPodEndpoint = "dnspod.intl.tencentcloudapi.com" + intlDomainEndpoint = "domain.intl.tencentcloudapi.com" +) + +type tencentCloudClient struct { + dnspodClient *dnspod.Client + domainClient *domain.Client + intlDomainClient *intldomain.Client + useIntlDomainClient bool +} + +func newClient(secretID, secretKey, region, dnspodEndpoint string, useIntlDomainClient bool) (*tencentCloudClient, error) { + credential := common.NewCredential(secretID, secretKey) + + dnspodProfile := profile.NewClientProfile() + if dnspodEndpoint != "" { + dnspodProfile.HttpProfile.Endpoint = dnspodEndpoint + } + + dpc, err := dnspod.NewClient(credential, region, dnspodProfile) + if err != nil { + return nil, fmt.Errorf("failed to create dnspod client: %w", err) + } + + client := &tencentCloudClient{ + dnspodClient: dpc, + useIntlDomainClient: useIntlDomainClient, + } + + if useIntlDomainClient { + intlCredential := intlcommon.NewCredential(secretID, secretKey) + intlDomainProfile := intlprofile.NewClientProfile() + intlDomainProfile.HttpProfile.Endpoint = intlDomainEndpoint + + idc, err := intldomain.NewClient(intlCredential, region, intlDomainProfile) + if err != nil { + return nil, fmt.Errorf("failed to create intl domain client: %w", err) + } + client.intlDomainClient = idc + return client, nil + } + + domainProfile := profile.NewClientProfile() + dmc, err := domain.NewClient(credential, region, domainProfile) + if err != nil { + return nil, fmt.Errorf("failed to create domain client: %w", err) + } + client.domainClient = dmc + + return client, nil +} + +func (c *tencentCloudClient) fetchRecords(domainName string) ([]*dnspod.RecordListItem, error) { + var records []*dnspod.RecordListItem + var offset uint64 = 0 + var limit uint64 = 1000 + + for { + request := dnspod.NewDescribeRecordListRequest() + request.Domain = new(domainName) + request.Offset = new(offset) + request.Limit = new(limit) + + response, err := c.dnspodClient.DescribeRecordList(request) + if err != nil { + return nil, err + } + + records = append(records, response.Response.RecordList...) + + if uint64(len(records)) >= *response.Response.RecordCountInfo.TotalCount { + break + } + offset += limit + } + + return records, nil +} + +func (c *tencentCloudClient) getNameservers(domainName string) ([]string, error) { + request := dnspod.NewDescribeDomainRequest() + request.Domain = new(domainName) + + response, err := c.dnspodClient.DescribeDomain(request) + if err != nil { + return nil, err + } + + var nss []string + for _, ns := range response.Response.DomainInfo.DnspodNsList { + nss = append(nss, *ns) + } + return nss, nil +} + +func (c *tencentCloudClient) getMinTTL(domainName string) (uint32, error) { + request := dnspod.NewDescribeDomainRequest() + request.Domain = new(domainName) + + response, err := c.dnspodClient.DescribeDomain(request) + if err != nil { + return 0, err + } + if response.Response == nil || response.Response.DomainInfo == nil || response.Response.DomainInfo.Grade == nil { + return defaultTTL, nil + } + grade := *response.Response.DomainInfo.Grade + + packageRequest := dnspod.NewDescribePackageDetailRequest() + packageResponse, err := c.dnspodClient.DescribePackageDetail(packageRequest) + if err != nil { + return 0, err + } + if packageResponse.Response == nil { + return defaultTTL, nil + } + + return minTTLForGrade(grade, packageResponse.Response.Info), nil +} + +func minTTLForGrade(grade string, packages []*dnspod.PackageDetailItem) uint32 { + for _, item := range packages { + if item.DomainGrade == nil || *item.DomainGrade != grade || item.MinTtl == nil { + continue + } + return uint32(*item.MinTtl) + } + return defaultTTL +} + +func (c *tencentCloudClient) getRegistrarNameservers(domainName string) ([]string, error) { + request := dnspod.NewDescribeDomainWhoisRequest() + request.Domain = new(domainName) + + response, err := c.dnspodClient.DescribeDomainWhois(request) + if err != nil { + return nil, err + } + + var nss []string + for _, ns := range response.Response.Info.NameServers { + nss = append(nss, *ns) + } + return nss, nil +} + +func (c *tencentCloudClient) updateRegistrarNameservers(domainName string, nss []string) error { + if c.useIntlDomainClient { + return c.updateIntlRegistrarNameservers(domainName, nss) + } + + request := domain.NewModifyDomainDNSBatchRequest() + request.Domains = common.StringPtrs([]string{domainName}) + request.Dns = common.StringPtrs(nss) + + response, err := c.domainClient.ModifyDomainDNSBatch(request) + if err != nil { + return err + } + if response.Response == nil || response.Response.LogId == nil { + return nil + } + return c.waitForDomainBatch(*response.Response.LogId, domainName) +} + +func (c *tencentCloudClient) updateIntlRegistrarNameservers(domainName string, nss []string) error { + request := intldomain.NewBatchModifyIntlDomainDNSRequest() + request.Domains = intlcommon.StringPtrs([]string{domainName}) + request.Dns = intlcommon.StringPtrs(nss) + + response, err := c.intlDomainClient.BatchModifyIntlDomainDNS(request) + if err != nil { + return err + } + if response.Response == nil || response.Response.LogId == nil { + return nil + } + return c.waitForIntlDomainBatch(*response.Response.LogId, domainName) +} + +func (c *tencentCloudClient) waitForDomainBatch(logID uint64, domainName string) error { + for range domainBatchPollAttempts { + request := domain.NewDescribeBatchOperationLogDetailsRequest() + request.LogId = new(int64(logID)) + request.Offset = common.Int64Ptr(0) + request.Limit = common.Int64Ptr(200) + + response, err := c.domainClient.DescribeBatchOperationLogDetails(request) + if err != nil { + return err + } + if response.Response != nil { + status, reason, found := domainBatchStatus(response.Response.DomainBatchDetailSet, domainName) + switch status { + case "success": + return nil + case "failed": + if reason == "" { + reason = "unknown reason" + } + return fmt.Errorf("tencent domain batch operation %d failed for %s: %s", logID, domainName, reason) + case "doing": + // Keep polling. + default: + if found { + return fmt.Errorf("tencent domain batch operation %d returned unexpected status %q for %s", logID, status, domainName) + } + } + } + + time.Sleep(domainBatchPollInterval) + } + return fmt.Errorf("timed out waiting for tencent domain batch operation %d for %s", logID, domainName) +} + +func domainBatchStatus(details []*domain.DomainBatchDetailSet, domainName string) (status, reason string, found bool) { + for _, detail := range details { + if detail.Domain == nil || !strings.EqualFold(*detail.Domain, domainName) { + continue + } + found = true + if detail.Status != nil { + status = *detail.Status + } + if detail.Reason != nil { + reason = *detail.Reason + } + return status, reason, found + } + return "", "", false +} + +func (c *tencentCloudClient) waitForIntlDomainBatch(logID uint64, domainName string) error { + for range domainBatchPollAttempts { + request := intldomain.NewDescribeIntlDomainBatchDetailsRequest() + request.LogId = new(int64(logID)) + request.Offset = intlcommon.Int64Ptr(0) + request.Limit = intlcommon.Int64Ptr(100) + + response, err := c.intlDomainClient.DescribeIntlDomainBatchDetails(request) + if err != nil { + return err + } + if response.Response != nil { + status, reason, found := intlDomainBatchStatus(response.Response.DomainBatchDetailSet, domainName) + switch strings.ToLower(status) { + case "success": + return nil + case "failure", "failed": + if reason == "" { + reason = "unknown reason" + } + return fmt.Errorf("tencent intl domain batch operation %d failed for %s: %s", logID, domainName, reason) + case "", "doing": + // Keep polling. + default: + if found { + return fmt.Errorf("tencent intl domain batch operation %d returned unexpected status %q for %s", logID, status, domainName) + } + } + } + + time.Sleep(domainBatchPollInterval) + } + return fmt.Errorf("timed out waiting for tencent intl domain batch operation %d for %s", logID, domainName) +} + +func intlDomainBatchStatus(details []*intldomain.BatchDomainBuyDetails, domainName string) (status, reason string, found bool) { + for _, detail := range details { + if detail.Domain == nil || !strings.EqualFold(*detail.Domain, domainName) { + continue + } + found = true + if detail.Status != nil { + status = *detail.Status + } + if detail.Reason != nil { + reason = *detail.Reason + } + if reason == "" && detail.ReasonZh != nil { + reason = *detail.ReasonZh + } + return status, reason, found + } + return "", "", false +} + +func (c *tencentCloudClient) createRecord(domainName string, request *dnspod.CreateRecordRequest) error { + request.Domain = new(domainName) + _, err := c.dnspodClient.CreateRecord(request) + return err +} + +func (c *tencentCloudClient) modifyRecord(domainName string, request *dnspod.ModifyRecordRequest) error { + request.Domain = new(domainName) + _, err := c.dnspodClient.ModifyRecord(request) + return err +} + +func (c *tencentCloudClient) deleteRecord(domainName string, recordID uint64) error { + request := dnspod.NewDeleteRecordRequest() + request.Domain = new(domainName) + request.RecordId = new(recordID) + _, err := c.dnspodClient.DeleteRecord(request) + return err +} diff --git a/providers/tencentdns/auditrecords.go b/providers/tencentdns/auditrecords.go new file mode 100644 index 0000000000..9a8768ba10 --- /dev/null +++ b/providers/tencentdns/auditrecords.go @@ -0,0 +1,20 @@ +package tencentdns + +import ( + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/DNSControl/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{} + + a.Add("MX", rejectif.MxNull) + a.Add("TXT", rejectif.TxtIsEmpty) + a.Add("SRV", rejectif.SrvHasNullTarget) + a.Add("SRV", rejectif.SrvHasEmptyTarget) + + return a.Audit(records) +} diff --git a/providers/tencentdns/auditrecords_test.go b/providers/tencentdns/auditrecords_test.go new file mode 100644 index 0000000000..a01fe20c45 --- /dev/null +++ b/providers/tencentdns/auditrecords_test.go @@ -0,0 +1,33 @@ +package tencentdns + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/stretchr/testify/assert" +) + +func TestAuditRecords(t *testing.T) { + mxNull := &models.RecordConfig{Type: "MX"} + assert.NoError(t, mxNull.SetTargetMX(0, ".")) + + txtEmpty := &models.RecordConfig{Type: "TXT"} + assert.NoError(t, txtEmpty.SetTargetTXT("")) + + srvNull := &models.RecordConfig{Type: "SRV"} + assert.NoError(t, srvNull.SetTargetSRV(0, 0, 1, ".")) + + srvEmpty := &models.RecordConfig{Type: "SRV"} + assert.NoError(t, srvEmpty.SetTargetSRV(0, 0, 1, "")) + + validA := &models.RecordConfig{Type: "A"} + validA.SetTarget("1.2.3.4") + + errs := AuditRecords(models.Records{mxNull, txtEmpty, srvNull, srvEmpty, validA}) + + assert.Len(t, errs, 4) + assert.Contains(t, errs[0].Error(), "mx has null target") + assert.Contains(t, errs[1].Error(), "txtstring is empty") + assert.Contains(t, errs[2].Error(), "srv has null target") + assert.Contains(t, errs[3].Error(), "srv has empty target") +} diff --git a/providers/tencentdns/convert.go b/providers/tencentdns/convert.go new file mode 100644 index 0000000000..790ffc4156 --- /dev/null +++ b/providers/tencentdns/convert.go @@ -0,0 +1,84 @@ +package tencentdns + +import ( + "fmt" + + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/DNSControl/dnscontrol/v4/pkg/txtutil" + dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" +) + +func nativeToRecord(r *dnspod.RecordListItem, domainName string) (*models.RecordConfig, error) { + rc := &models.RecordConfig{ + TTL: uint32(*r.TTL), + Original: r, + } + rc.SetLabel(*r.Name, domainName) + + val := *r.Value + switch *r.Type { + case "A", "AAAA", "CNAME", "NS", "PTR", "TXT", "CAA", "SRV": + case "MX": + if r.MX != nil { + val = fmt.Sprintf("%d %s", *r.MX, *r.Value) + } + default: + return nil, fmt.Errorf("unsupported record type: %s", *r.Type) + } + + // DNSPod does not have a native ALIAS record type. DNSControl uses + // ALIAS("@") to model apex CNAME flattening, which DNSPod represents + // as a CNAME record at "@". + // See https://docs.dnspod.com/dns/faq-dns-resolution/?lang=en. + rtype := *r.Type + if rtype == "CNAME" && *r.Name == "@" { + rtype = "ALIAS" + } + + if err := rc.PopulateFromStringFunc(rtype, val, domainName, txtutil.ParseQuoted); err != nil { + return nil, err + } + + return rc, nil +} + +func recordToCreateRequest(rc *models.RecordConfig) *dnspod.CreateRecordRequest { + req := dnspod.NewCreateRecordRequest() + req.SubDomain = new(rc.GetLabel()) + req.RecordType = new(rc.Type) + if rc.Type == "ALIAS" { + req.RecordType = new("CNAME") + } + req.RecordLine = new("默认") + + val := rc.GetTargetCombinedFunc(txtutil.EncodeQuoted) + if rc.Type == "MX" { + val = rc.GetTargetField() + req.MX = new(uint64(rc.MxPreference)) + } + req.Value = new(val) + req.TTL = new(uint64(rc.TTL)) + + return req +} + +func recordToModifyRequest(rc *models.RecordConfig, recordID uint64) *dnspod.ModifyRecordRequest { + req := dnspod.NewModifyRecordRequest() + req.RecordId = new(recordID) + req.SubDomain = new(rc.GetLabel()) + req.RecordType = new(rc.Type) + if rc.Type == "ALIAS" { + req.RecordType = new("CNAME") + } + req.RecordLine = new("默认") + + val := rc.GetTargetCombinedFunc(txtutil.EncodeQuoted) + if rc.Type == "MX" { + val = rc.GetTargetField() + req.MX = new(uint64(rc.MxPreference)) + } + req.Value = new(val) + req.TTL = new(uint64(rc.TTL)) + + return req +} diff --git a/providers/tencentdns/convert_test.go b/providers/tencentdns/convert_test.go new file mode 100644 index 0000000000..19223413cb --- /dev/null +++ b/providers/tencentdns/convert_test.go @@ -0,0 +1,113 @@ +package tencentdns + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/stretchr/testify/assert" + dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" +) + +func TestNativeToRecord(t *testing.T) { + domain := "example.com" + + tests := []struct { + name string + input *dnspod.RecordListItem + expected *models.RecordConfig + }{ + { + name: "Basic A record", + input: &dnspod.RecordListItem{ + Name: new("@"), + Type: new("A"), + Value: new("1.2.3.4"), + TTL: new(uint64(600)), + }, + expected: &models.RecordConfig{ + Type: "A", + TTL: 600, + }, + }, + { + name: "CNAME record", + input: &dnspod.RecordListItem{ + Name: new("www"), + Type: new("CNAME"), + Value: new("target.example.com."), + TTL: new(uint64(300)), + }, + expected: &models.RecordConfig{ + Type: "CNAME", + TTL: 300, + }, + }, + { + name: "MX record", + input: &dnspod.RecordListItem{ + Name: new("@"), + Type: new("MX"), + Value: new("mail.example.com."), + TTL: new(uint64(600)), + MX: new(uint64(10)), + }, + expected: &models.RecordConfig{ + Type: "MX", + TTL: 600, + MxPreference: 10, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc, err := nativeToRecord(tt.input, domain) + if err != nil { + t.Fatalf("nativeToRecord failed: %v", err) + } + assert.Equal(t, tt.expected.Type, rc.Type) + assert.Equal(t, tt.expected.TTL, rc.TTL) + if tt.expected.Type == "MX" { + assert.Equal(t, tt.expected.MxPreference, rc.MxPreference) + } + expectedLabel := tt.expected.GetLabel() + if expectedLabel == "" { + expectedLabel = *tt.input.Name + } + assert.Equal(t, expectedLabel, rc.GetLabel()) + }) + } +} + +func TestRecordToCreateRequest(t *testing.T) { + domain := "example.com" + rc := &models.RecordConfig{ + Type: "A", + TTL: 600, + } + rc.SetLabel("test", domain) + rc.SetTarget("1.1.1.1") + + req := recordToCreateRequest(rc) + assert.Equal(t, "test", *req.SubDomain) + assert.Equal(t, "A", *req.RecordType) + assert.Equal(t, "1.1.1.1", *req.Value) + assert.Equal(t, uint64(600), *req.TTL) +} + +func TestRecordToCreateRequest_MX(t *testing.T) { + domain := "example.com" + rc := &models.RecordConfig{ + Type: "MX", + TTL: 600, + MxPreference: 10, + } + rc.SetLabel("@", domain) + rc.SetTarget("mail.example.com.") + + req := recordToCreateRequest(rc) + assert.Equal(t, "@", *req.SubDomain) + assert.Equal(t, "MX", *req.RecordType) + assert.Equal(t, "mail.example.com.", *req.Value) + assert.Equal(t, uint64(10), *req.MX) +} diff --git a/providers/tencentdns/tencentdnsProvider.go b/providers/tencentdns/tencentdnsProvider.go new file mode 100644 index 0000000000..ca3619e575 --- /dev/null +++ b/providers/tencentdns/tencentdnsProvider.go @@ -0,0 +1,294 @@ +package tencentdns + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/DNSControl/dnscontrol/v4/pkg/diff2" + "github.com/DNSControl/dnscontrol/v4/pkg/providers" + dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" +) + +const defaultTTL = uint32(600) + +var features = providers.DocumentationNotes{ + providers.CanUseAlias: providers.Can("DNSPod doesn't natively support the ALIAS record type."), + providers.CanGetZones: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Can("Tencent Cloud allows full management of apex NS records"), + providers.DocOfficiallySupported: providers.Cannot(), +} + +func init() { + const providerName = "TENCENTDNS" + const providerMaintainer = "" + fns := providers.DspFuncs{ + Initializer: newTencentDNSDsp, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterRegistrarType(providerName, newTencentDNSReg) + providers.RegisterMaintainer(providerName, providerMaintainer) + // Default TTL for Tencent Cloud DNSPod is 600 for free plan. + providers.RegisterDefaultTTL(providerName, defaultTTL) + providers.RegisterCredsMetadata(providerName, providers.CredsMetadata{ + DisplayName: "Tencent Cloud DNS", + Kind: providers.KindDNS | providers.KindRegistrar, + DocsURL: "https://docs.dnscontrol.org/provider/tencentdns", + PortalURL: "https://console.intl.cloud.tencent.com/cam/capi", + Fields: []providers.CredsField{ + { + Key: "secret_id", + Label: "Secret ID", + Help: "Tencent Cloud SecretId.", + Required: true, + Secret: true, + }, + { + Key: "secret_key", + Label: "Secret Key", + Help: "Tencent Cloud SecretKey.", + Required: true, + Secret: true, + }, + { + Key: "region", + Label: "Region", + Help: "The region value does not affect DNS management (DNS is global).", + Default: "ap-guangzhou", + }, + { + Key: "site", + Label: "Site", + Help: "Tencent Cloud site. Use cn for mainland China or intl for international APIs.", + Default: "cn", + }, + }, + }) +} + +type tencentdnsProvider struct { + client *tencentCloudClient +} + +func newTencentDNSDsp(config map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + return newTencentDNS(config) +} + +func newTencentDNSReg(config map[string]string) (providers.Registrar, error) { + return newTencentDNS(config) +} + +func newTencentDNS(config map[string]string) (*tencentdnsProvider, error) { + secretID := config["secret_id"] + secretKey := config["secret_key"] + if secretID == "" || secretKey == "" { + return nil, fmt.Errorf("missing tencent cloud credentials (secret_id, secret_key)") + } + + region := config["region"] + if region == "" { + region = "ap-guangzhou" + } + + siteConfig, err := siteConfigForSite(config["site"]) + if err != nil { + return nil, err + } + + client, err := newClient(secretID, secretKey, region, siteConfig.dnspodEndpoint, siteConfig.useIntlDomainClient) + if err != nil { + return nil, err + } + + return &tencentdnsProvider{ + client: client, + }, nil +} + +type tencentSiteConfig struct { + dnspodEndpoint string + useIntlDomainClient bool +} + +func siteConfigForSite(site string) (tencentSiteConfig, error) { + switch strings.ToLower(site) { + case "", "cn", "china": + return tencentSiteConfig{}, nil + case "intl", "international": + return tencentSiteConfig{ + dnspodEndpoint: intlDNSPodEndpoint, + useIntlDomainClient: true, + }, nil + default: + return tencentSiteConfig{}, fmt.Errorf("unsupported tencent cloud site %q: expected cn or intl", site) + } +} + +func (p *tencentdnsProvider) ListZones() ([]string, error) { + // For simplicity, we just use the API to list all domains. + // In a real implementation, we might want to handle pagination better. + request := dnspod.NewDescribeDomainListRequest() + response, err := p.client.dnspodClient.DescribeDomainList(request) + if err != nil { + return nil, err + } + + var zones []string + for _, domain := range response.Response.DomainList { + zones = append(zones, *domain.Name) + } + return zones, nil +} + +func (p *tencentdnsProvider) GetNameservers(domainName string) ([]*models.Nameserver, error) { + nss, err := p.client.getNameservers(domainName) + if err != nil { + if strings.Contains(err.Error(), "DomainNotExists") || strings.Contains(err.Error(), "域名有误") { + return nil, nil + } + return nil, err + } + return models.ToNameservers(nss) +} + +func (p *tencentdnsProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records, error) { + records, err := p.client.fetchRecords(dc.Name) + if err != nil { + if strings.Contains(err.Error(), "DomainNotExists") { + return nil, nil + } + return nil, err + } + + existingRecords := models.Records{} + for _, r := range records { + if *r.Status != "ENABLE" { + continue + } + rc, err := nativeToRecord(r, dc.Name) + if err != nil { + return nil, err + } + existingRecords = append(existingRecords, rc) + } + return existingRecords, nil +} + +func prepDesiredRecords(dc *models.DomainConfig, minTTL uint32) { + for _, rec := range dc.Records { + if rec.TTL != 0 && rec.TTL < minTTL { + rec.TTL = minTTL + } + } +} + +func (p *tencentdnsProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + var corrections []*models.Correction + + minTTL, err := p.client.getMinTTL(dc.Name) + if err != nil { + return nil, 0, err + } + prepDesiredRecords(dc, minTTL) + + // Tencent Cloud is a "ByRecord" API. + changes, actualChangeCount, err := diff2.ByRecord(existingRecords, dc, nil) + if err != nil { + return nil, 0, err + } + + for _, change := range changes { + msgs := change.MsgsJoined + domainName := dc.Name + + switch change.Type { + case diff2.REPORT: + corrections = append(corrections, &models.Correction{Msg: msgs}) + case diff2.CREATE: + rc := change.New[0] + corrections = append(corrections, &models.Correction{ + Msg: msgs, + F: func() error { + return p.client.createRecord(domainName, recordToCreateRequest(rc)) + }, + }) + case diff2.CHANGE: + rc := change.New[0] + recordID := *(change.Old[0].Original.(*dnspod.RecordListItem).RecordId) + corrections = append(corrections, &models.Correction{ + Msg: msgs, + F: func() error { + return p.client.modifyRecord(domainName, recordToModifyRequest(rc, recordID)) + }, + }) + case diff2.DELETE: + recordID := *(change.Old[0].Original.(*dnspod.RecordListItem).RecordId) + corrections = append(corrections, &models.Correction{ + Msg: msgs, + F: func() error { + return p.client.deleteRecord(domainName, recordID) + }, + }) + } + } + + return corrections, actualChangeCount, nil +} + +func (p *tencentdnsProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + actualSet, err := p.client.getRegistrarNameservers(dc.Name) + if err != nil { + return nil, err + } + actualSet = normalizeNameserverSet(actualSet) + actual := strings.Join(actualSet, ",") + + expectedSet := []string{} + for _, ns := range dc.Nameservers { + expectedSet = append(expectedSet, ns.Name) + } + expectedSet = normalizeNameserverSet(expectedSet) + expected := strings.Join(expectedSet, ",") + + if actual != expected { + return []*models.Correction{ + { + Msg: fmt.Sprintf("Update nameservers %s -> %s", actual, expected), + F: func() error { + return p.client.updateRegistrarNameservers(dc.Name, expectedSet) + }, + }, + }, nil + } + + return nil, nil +} + +func normalizeNameserverSet(nameservers []string) []string { + normalized := make([]string, 0, len(nameservers)) + for _, ns := range nameservers { + normalized = append(normalized, strings.ToLower(strings.TrimSuffix(ns, "."))) + } + sort.Strings(normalized) + return normalized +} + +func (p *tencentdnsProvider) EnsureZoneExists(domainName string, metadata map[string]string) error { + request := dnspod.NewCreateDomainRequest() + request.Domain = &domainName + _, err := p.client.dnspodClient.CreateDomain(request) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + return nil + } + return err + } + return nil +} diff --git a/providers/tencentdns/tencentdnsProvider_test.go b/providers/tencentdns/tencentdnsProvider_test.go new file mode 100644 index 0000000000..92d3fe5965 --- /dev/null +++ b/providers/tencentdns/tencentdnsProvider_test.go @@ -0,0 +1,264 @@ +package tencentdns + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v4/models" + "github.com/DNSControl/dnscontrol/v4/pkg/providers" + "github.com/stretchr/testify/assert" + intldomain "github.com/tencentcloud/tencentcloud-sdk-go-intl-en/tencentcloud/domain/v20180808" + dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" + domain "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/domain/v20180808" +) + +func TestNewTencentDNS(t *testing.T) { + config := map[string]string{ + "secret_id": "test-id", + "secret_key": "test-key", + "region": "ap-guangzhou", + } + + provider, err := newTencentDNS(config) + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.NotNil(t, provider.client) + assert.False(t, provider.client.useIntlDomainClient) + assert.NotNil(t, provider.client.domainClient) + assert.Nil(t, provider.client.intlDomainClient) +} + +func TestNewTencentDNS_IntlSite(t *testing.T) { + config := map[string]string{ + "secret_id": "test-id", + "secret_key": "test-key", + "region": "ap-guangzhou", + "site": "intl", + } + + provider, err := newTencentDNS(config) + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.NotNil(t, provider.client) + assert.True(t, provider.client.useIntlDomainClient) + assert.Nil(t, provider.client.domainClient) + assert.NotNil(t, provider.client.intlDomainClient) +} + +func TestNewTencentDNS_MissingCreds(t *testing.T) { + config := map[string]string{ + "secret_id": "test-id", + // "secret_key" is missing + } + + provider, err := newTencentDNS(config) + assert.Error(t, err) + assert.Nil(t, provider) +} + +func TestNewTencentDNS_UnsupportedSite(t *testing.T) { + config := map[string]string{ + "secret_id": "test-id", + "secret_key": "test-key", + "site": "moon", + } + + provider, err := newTencentDNS(config) + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "unsupported tencent cloud site") +} + +func TestSiteConfigForSite(t *testing.T) { + tests := []struct { + name string + site string + endpoint string + useIntlDomainClient bool + }{ + { + name: "default", + }, + { + name: "china", + site: "cn", + }, + { + name: "intl", + site: "intl", + endpoint: intlDNSPodEndpoint, + useIntlDomainClient: true, + }, + { + name: "international", + site: "international", + endpoint: intlDNSPodEndpoint, + useIntlDomainClient: true, + }, + { + name: "mixed case", + site: "InTl", + endpoint: intlDNSPodEndpoint, + useIntlDomainClient: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + siteConfig, err := siteConfigForSite(tc.site) + assert.NoError(t, err) + assert.Equal(t, tc.endpoint, siteConfig.dnspodEndpoint) + assert.Equal(t, tc.useIntlDomainClient, siteConfig.useIntlDomainClient) + }) + } +} + +func TestPrepDesiredRecordsRewritesLowTTL(t *testing.T) { + dc := &models.DomainConfig{ + Records: models.Records{ + {TTL: 0}, + {TTL: 300}, + {TTL: 600}, + {TTL: 3600}, + }, + } + + prepDesiredRecords(dc, 600) + + assert.Equal(t, uint32(0), dc.Records[0].TTL) + assert.Equal(t, uint32(600), dc.Records[1].TTL) + assert.Equal(t, uint32(600), dc.Records[2].TTL) + assert.Equal(t, uint32(3600), dc.Records[3].TTL) +} + +func TestPrepDesiredRecordsAllowsPaidDomainTTL(t *testing.T) { + dc := &models.DomainConfig{ + Records: models.Records{ + {TTL: 300}, + }, + } + + prepDesiredRecords(dc, 1) + + assert.Equal(t, uint32(300), dc.Records[0].TTL) +} + +func TestMinTTLForGrade(t *testing.T) { + packages := []*dnspod.PackageDetailItem{ + { + DomainGrade: new("DP_Free"), + MinTtl: new(uint64(600)), + }, + { + DomainGrade: new("DP_Plus"), + MinTtl: new(uint64(1)), + }, + { + DomainGrade: new("DP_MissingTTL"), + }, + } + + assert.Equal(t, uint32(600), minTTLForGrade("DP_Free", packages)) + assert.Equal(t, uint32(1), minTTLForGrade("DP_Plus", packages)) + assert.Equal(t, defaultTTL, minTTLForGrade("DP_MissingTTL", packages)) + assert.Equal(t, defaultTTL, minTTLForGrade("DP_Unknown", packages)) +} + +func TestCredsMetadata(t *testing.T) { + meta, ok := providers.GetCredsMetadata("TENCENTDNS") + assert.True(t, ok) + assert.Equal(t, "Tencent Cloud DNS", meta.DisplayName) + assert.True(t, meta.Kind.Has(providers.KindDNS)) + assert.True(t, meta.Kind.Has(providers.KindRegistrar)) + assert.Equal(t, "https://docs.dnscontrol.org/provider/tencentdns", meta.DocsURL) + assert.Equal(t, "https://console.intl.cloud.tencent.com/cam/capi", meta.PortalURL) + + if assert.Len(t, meta.Fields, 4) { + assert.Equal(t, "secret_id", meta.Fields[0].Key) + assert.True(t, meta.Fields[0].Required) + assert.True(t, meta.Fields[0].Secret) + + assert.Equal(t, "secret_key", meta.Fields[1].Key) + assert.True(t, meta.Fields[1].Required) + assert.True(t, meta.Fields[1].Secret) + + assert.Equal(t, "region", meta.Fields[2].Key) + assert.Equal(t, "ap-guangzhou", meta.Fields[2].Default) + + assert.Equal(t, "site", meta.Fields[3].Key) + assert.Equal(t, "cn", meta.Fields[3].Default) + assert.Contains(t, meta.Fields[3].Help, "international APIs") + } +} + +func TestDomainBatchStatus(t *testing.T) { + details := []*domain.DomainBatchDetailSet{ + { + Domain: new("example.com"), + Status: new("failed"), + Reason: new("invalid dns"), + }, + } + + status, reason, found := domainBatchStatus(details, "EXAMPLE.COM") + + assert.True(t, found) + assert.Equal(t, "failed", status) + assert.Equal(t, "invalid dns", reason) +} + +func TestDomainBatchStatusNotFound(t *testing.T) { + status, reason, found := domainBatchStatus(nil, "example.com") + + assert.False(t, found) + assert.Empty(t, status) + assert.Empty(t, reason) +} + +func TestIntlDomainBatchStatus(t *testing.T) { + details := []*intldomain.BatchDomainBuyDetails{ + { + Domain: new("example.com"), + Status: new("FAILURE"), + Reason: new("invalid dns"), + }, + } + + status, reason, found := intlDomainBatchStatus(details, "EXAMPLE.COM") + + assert.True(t, found) + assert.Equal(t, "FAILURE", status) + assert.Equal(t, "invalid dns", reason) +} + +func TestIntlDomainBatchStatusUsesReasonZh(t *testing.T) { + details := []*intldomain.BatchDomainBuyDetails{ + { + Domain: new("example.com"), + Status: new("FAILURE"), + ReasonZh: new("localized dns error"), + }, + } + + status, reason, found := intlDomainBatchStatus(details, "example.com") + + assert.True(t, found) + assert.Equal(t, "FAILURE", status) + assert.Equal(t, "localized dns error", reason) +} + +func TestIntlDomainBatchStatusNotFound(t *testing.T) { + status, reason, found := intlDomainBatchStatus(nil, "example.com") + + assert.False(t, found) + assert.Empty(t, status) + assert.Empty(t, reason) +} + +func TestNormalizeNameserverSet(t *testing.T) { + got := normalizeNameserverSet([]string{ + "NANCY.NS.CLOUDFLARE.COM.", + "rudy.ns.cloudflare.com", + }) + + assert.Equal(t, []string{"nancy.ns.cloudflare.com", "rudy.ns.cloudflare.com"}, got) +}