From 68ff6056d79bf8db9662f8b59dbccc153c2edabe Mon Sep 17 00:00:00 2001 From: cylonchau Date: Sun, 10 May 2026 01:32:23 +0800 Subject: [PATCH 1/2] new provider: Add Tencent Cloud DNS (tencentdns/dnspod) provider and registrar Add support for Tencent Cloud DNS (encentdns/dnspod) as both DNS provider and registrar using the official Tencent Cloud API 3.0. Features: - DNS Provider: Full CRUD for A, AAAA, CNAME, MX, NS, TXT, CAA, SRV records - Registrar: Nameserver delegation management at the registry level - Apex CNAME support: Seamlessly maps ALIAS records to Tencent's apex CNAME - Zone management: Supports automatic zone creation (EnsureZoneExists) - Zone listing: Supports the get-zones command for easy migration - Incremental updates: Full support for NO_PURGE and IGNORE via diff2 engine Technical details: - Based on tencentcloud-sdk-go (API 3.0) - Implements RecordAuditor to comply with DNSControl v4 requirements - Handles free-tier limitations (TTL minimum 600s) automatically Documentation and CI/CD configuration (GitHub Actions profiles) included. --- .github/CODEOWNERS | 1 + .github/workflows/pr_integration_tests.yml | 2 + .goreleaser.yml | 2 +- README.md | 4 +- documentation/provider/index.md | 12 +- documentation/provider/tencentdns.md | 63 ++++++ go.mod | 3 + go.sum | 8 + integrationTest/profiles.json | 6 + pkg/providers/_all/all.go | 1 + providers/tencentdns/api.go | 125 +++++++++++ providers/tencentdns/auditrecords.go | 18 ++ providers/tencentdns/convert.go | 90 ++++++++ providers/tencentdns/convert_test.go | 114 ++++++++++ providers/tencentdns/tencentdnsProvider.go | 210 ++++++++++++++++++ .../tencentdns/tencentdnsProvider_test.go | 31 +++ 16 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 documentation/provider/tencentdns.md create mode 100644 providers/tencentdns/api.go create mode 100644 providers/tencentdns/auditrecords.go create mode 100644 providers/tencentdns/convert.go create mode 100644 providers/tencentdns/convert_test.go create mode 100644 providers/tencentdns/tencentdnsProvider.go create mode 100644 providers/tencentdns/tencentdnsProvider_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6813b435ae..db7381da1d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -60,6 +60,7 @@ providers/transip @blackshadev providers/unifi @zupolgec providers/vercel @SukkaW providers/vultr @pgaskin +providers/tencentdns @cylonchau * @TomOnTime .goreleaser.yml @cafferata @TomOnTime diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index f4e05a45d7..1c7ec5d793 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -227,6 +227,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 57fe0aa6df..a8070bfb27 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|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|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 644ff1ae37..7afb8f3d6c 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) | | | | +| [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) | | | ¹also supports registrar functions ²registrar only diff --git a/documentation/provider/index.md b/documentation/provider/index.md index cf6a65eacf..4f80d705c9 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -80,6 +80,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) | ❌ | ✅ | ❌ | @@ -147,6 +148,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) | ❔ | ❌ | ❌ | ❌ | @@ -209,6 +211,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) | ✅ | ❌ | ❌ | ❌ | ❌ | @@ -270,6 +273,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) | ❌ | ❌ | ✅ | ❌ | @@ -372,6 +376,7 @@ Jump to a table: | [`POWERDNS`](powerdns.md) | ✅ | ✅ | ✅ | | [`REALTIMEREGISTER`](realtimeregister.md) | ✅ | ❔ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ❌ | +| [`TENCENTDNS`](tencentdns.md) | ❌ | ❔ | ❔ | | [`TRANSIP`](transip.md) | ❌ | ❌ | ❌ | | [`VERCEL`](vercel.md) | ❌ | ❌ | ❌ | @@ -461,9 +466,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..222dc68f38 --- /dev/null +++ b/documentation/provider/tencentdns.md @@ -0,0 +1,63 @@ +## 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/cns) (DNSPod). To use this provider, add an entry to `creds.json` with `TYPE` set to `TENCENTDNS` along with your API credentials. + +Example: + +{% code title="creds.json" %} +```json +{ + "tencentdns": { + "TYPE": "TENCENTDNS", + "secret_id": "YOUR_SECRET_ID", + "secret_key": "YOUR_SECRET_KEY" + } +} +``` +{% 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" + } +} +``` +{% endcode %} + +## 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 %} + +## Important Notes + +### Features + +- **MX Records**: Priority and target are handled automatically. +- **Registrar Support**: Supports updating authoritative nameservers for domains registered with Tencent Cloud. +- **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..425b7ae5e0 100644 --- a/go.mod +++ b/go.mod @@ -100,6 +100,9 @@ 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/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..2fdec6edcf 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,14 @@ 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/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 b20a260d29..f3a1df34e2 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -353,6 +353,12 @@ "domain": "$SL_DOMAIN", "username": "$SL_USERNAME" }, + "TENCENTDNS": { + "TYPE": "TENCENTDNS", + "secret_id": "$TENCENTDNS_SECRET_ID", + "secret_key": "$TENCENTDNS_SECRET_KEY", + "domain": "$TENCENTDNS_DOMAIN" + }, "TRANSIP": { "AccessToken": "$TRANSIP_ACCESS_TOKEN", "AccountName": "$TRANSIP_ACCOUNT_NAME", diff --git a/pkg/providers/_all/all.go b/pkg/providers/_all/all.go index b73eea8d49..0972443ea6 100644 --- a/pkg/providers/_all/all.go +++ b/pkg/providers/_all/all.go @@ -61,6 +61,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..9b70ae7ce1 --- /dev/null +++ b/providers/tencentdns/api.go @@ -0,0 +1,125 @@ +package tencentdns + +import ( + "fmt" + + "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" +) + +type tencentCloudClient struct { + dnspodClient *dnspod.Client + domainClient *domain.Client +} + +func newClient(secretId, secretKey, region string) (*tencentCloudClient, error) { + credential := common.NewCredential(secretId, secretKey) + cpf := profile.NewClientProfile() + + // DNSPod client + dpc, err := dnspod.NewClient(credential, region, cpf) + if err != nil { + return nil, fmt.Errorf("failed to create dnspod client: %w", err) + } + + // Domain client + dmc, err := domain.NewClient(credential, region, cpf) + if err != nil { + return nil, fmt.Errorf("failed to create domain client: %w", err) + } + + return &tencentCloudClient{ + dnspodClient: dpc, + domainClient: dmc, + }, 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 = common.StringPtr(domainName) + request.Offset = common.Uint64Ptr(offset) + request.Limit = common.Uint64Ptr(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 = common.StringPtr(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) getRegistrarNameservers(domainName string) ([]string, error) { + request := dnspod.NewDescribeDomainWhoisRequest() + request.Domain = common.StringPtr(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 { + request := domain.NewModifyDomainDNSBatchRequest() + request.Domains = common.StringPtrs([]string{domainName}) + request.Dns = common.StringPtrs(nss) + + _, err := c.domainClient.ModifyDomainDNSBatch(request) + return err +} + +func (c *tencentCloudClient) createRecord(domainName string, request *dnspod.CreateRecordRequest) error { + request.Domain = common.StringPtr(domainName) + _, err := c.dnspodClient.CreateRecord(request) + return err +} + +func (c *tencentCloudClient) modifyRecord(domainName string, request *dnspod.ModifyRecordRequest) error { + request.Domain = common.StringPtr(domainName) + _, err := c.dnspodClient.ModifyRecord(request) + return err +} + +func (c *tencentCloudClient) deleteRecord(domainName string, recordId uint64) error { + request := dnspod.NewDeleteRecordRequest() + request.Domain = common.StringPtr(domainName) + request.RecordId = common.Uint64Ptr(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..c8b0c13ab1 --- /dev/null +++ b/providers/tencentdns/auditrecords.go @@ -0,0 +1,18 @@ +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) + + return a.Audit(records) +} diff --git a/providers/tencentdns/convert.go b/providers/tencentdns/convert.go new file mode 100644 index 0000000000..3344c4eaad --- /dev/null +++ b/providers/tencentdns/convert.go @@ -0,0 +1,90 @@ +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": + // These are standard types, PopulateFromStringFunc handles them. + 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) + } + + 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 = commonStringPtr(rc.GetLabel()) + req.RecordType = commonStringPtr(rc.Type) + if rc.Type == "ALIAS" { + req.RecordType = commonStringPtr("CNAME") + } + req.RecordLine = commonStringPtr("默认") // Default line + + val := rc.GetTargetCombinedFunc(txtutil.EncodeQuoted) + if rc.Type == "MX" { + val = rc.GetTargetField() + req.MX = commonUint64Ptr(uint64(rc.MxPreference)) + } + req.Value = commonStringPtr(val) + req.TTL = commonUint64Ptr(uint64(rc.TTL)) + + return req +} + +func recordToModifyRequest(rc *models.RecordConfig, recordId uint64) *dnspod.ModifyRecordRequest { + req := dnspod.NewModifyRecordRequest() + req.RecordId = commonUint64Ptr(recordId) + req.SubDomain = commonStringPtr(rc.GetLabel()) + req.RecordType = commonStringPtr(rc.Type) + if rc.Type == "ALIAS" { + req.RecordType = commonStringPtr("CNAME") + } + req.RecordLine = commonStringPtr("默认") + + val := rc.GetTargetCombinedFunc(txtutil.EncodeQuoted) + if rc.Type == "MX" { + val = rc.GetTargetField() + req.MX = commonUint64Ptr(uint64(rc.MxPreference)) + } + req.Value = commonStringPtr(val) + req.TTL = commonUint64Ptr(uint64(rc.TTL)) + + return req +} + +// Helpers to avoid importing "common" in every file if possible, or just import it. +func commonStringPtr(s string) *string { + return &s +} + +func commonUint64Ptr(u uint64) *uint64 { + return &u +} diff --git a/providers/tencentdns/convert_test.go b/providers/tencentdns/convert_test.go new file mode 100644 index 0000000000..77b565f663 --- /dev/null +++ b/providers/tencentdns/convert_test.go @@ -0,0 +1,114 @@ +package tencentdns + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v4/models" + dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" + "github.com/stretchr/testify/assert" +) + +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: commonStringPtr("@"), + Type: commonStringPtr("A"), + Value: commonStringPtr("1.2.3.4"), + TTL: commonUint64Ptr(600), + }, + expected: &models.RecordConfig{ + Type: "A", + TTL: 600, + }, + }, + { + name: "CNAME record", + input: &dnspod.RecordListItem{ + Name: commonStringPtr("www"), + Type: commonStringPtr("CNAME"), + Value: commonStringPtr("target.example.com."), + TTL: commonUint64Ptr(300), + }, + expected: &models.RecordConfig{ + Type: "CNAME", + TTL: 300, + }, + }, + { + name: "MX record", + input: &dnspod.RecordListItem{ + Name: commonStringPtr("@"), + Type: commonStringPtr("MX"), + Value: commonStringPtr("mail.example.com."), + TTL: commonUint64Ptr(600), + MX: commonUint64Ptr(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) + } + // Verify label + 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) // Target only, no priority + 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..508684b45b --- /dev/null +++ b/providers/tencentdns/tencentdnsProvider.go @@ -0,0 +1,210 @@ +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" +) + +var features = providers.DocumentationNotes{ + providers.CanUseAlias: providers.Can(), + providers.CanGetZones: providers.Can(), + providers.CanUseCAA: 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 domains. + providers.RegisterDefaultTTL(providerName, 600) +} + +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" // Default region + } + + client, err := newClient(secretId, secretKey, region) + if err != nil { + return nil, err + } + + return &tencentdnsProvider{ + client: client, + }, nil +} + +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 (p *tencentdnsProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + var corrections []*models.Correction + + // 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 + } + sort.Strings(actualSet) + actual := strings.Join(actualSet, ",") + + expectedSet := []string{} + for _, ns := range dc.Nameservers { + expectedSet = append(expectedSet, ns.Name) + } + sort.Strings(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 (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..6c488ff3b1 --- /dev/null +++ b/providers/tencentdns/tencentdnsProvider_test.go @@ -0,0 +1,31 @@ +package tencentdns + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +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) +} + +func TestNewTencentDNS_MissingCreds(t *testing.T) { + config := map[string]string{ + "secret_id": "test-id", + // missing secret_key + } + + provider, err := newTencentDNS(config) + assert.Error(t, err) + assert.Nil(t, provider) +} From cb52553c513eddbae5362f7794c1d27f72ff6b2a Mon Sep 17 00:00:00 2001 From: cylonchau Date: Sat, 16 May 2026 01:37:41 +0800 Subject: [PATCH 2/2] TENCENTDNS: add metadata, TTL handling, and split cn and intl site APIs - Implement RegisterCredsMetadata for dnscontrol init - Rewrite low TTLs before diff2 using dnspod package limits - Add cn/intl site selection for dns and registrar API - Poll registrar batch operations and surface failures - Add docs and focused unit tests --- .github/CODEOWNERS | 2 +- documentation/provider/index.md | 6 +- documentation/provider/tencentdns.md | 31 ++- go.mod | 1 + go.sum | 2 + integrationTest/profiles.json | 3 +- providers/tencentdns/api.go | 248 ++++++++++++++++-- providers/tencentdns/auditrecords.go | 2 + providers/tencentdns/auditrecords_test.go | 33 +++ providers/tencentdns/convert.go | 38 +-- providers/tencentdns/convert_test.go | 22 +- providers/tencentdns/tencentdnsProvider.go | 94 ++++++- .../tencentdns/tencentdnsProvider_test.go | 233 ++++++++++++++++ 13 files changed, 650 insertions(+), 65 deletions(-) create mode 100644 providers/tencentdns/auditrecords_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db7381da1d..e1a0f5e849 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,11 +56,11 @@ 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 providers/vultr @pgaskin -providers/tencentdns @cylonchau * @TomOnTime .goreleaser.yml @cafferata @TomOnTime diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 4f80d705c9..77acb24c33 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -148,7 +148,7 @@ Jump to a table: | [`RWTH`](rwth.md) | ❔ | ❌ | ❌ | ✅ | | [`SAKURACLOUD`](sakuracloud.md) | ❔ | ❌ | ✅ | ✅ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | -| [`TENCENTDNS`](tencentdns.md) | ✅ | ✅ | ✅ | ✅ | +| [`TENCENTDNS`](tencentdns.md) | ❔ | ✅ | ✅ | ✅ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ✅ | | [`UNIFI`](unifi.md) | ❌ | ❔ | ❌ | ❌ | | [`VERCEL`](vercel.md) | ❔ | ❌ | ❌ | ❌ | @@ -211,7 +211,7 @@ Jump to a table: | [`RWTH`](rwth.md) | ❌ | ❔ | ❌ | ✅ | ❔ | | [`SAKURACLOUD`](sakuracloud.md) | ✅ | ❌ | ❌ | ✅ | ❌ | | [`SOFTLAYER`](softlayer.md) | ❔ | ❔ | ❌ | ❔ | ❔ | -| [`TENCENTDNS`](tencentdns.md) | ❌ | ❔ | ❔ | ✅ | ❔ | +| [`TENCENTDNS`](tencentdns.md) | ✅ | ❔ | ❔ | ✅ | ❔ | | [`TRANSIP`](transip.md) | ✅ | ❌ | ❌ | ❌ | ❌ | | [`UNIFI`](unifi.md) | ❌ | ❔ | ❌ | ❌ | ❔ | | [`VERCEL`](vercel.md) | ✅ | ❌ | ❌ | ❌ | ❌ | @@ -332,6 +332,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) | ✅ | ✅ | ❔ | ❌ | ❌ | @@ -376,7 +377,6 @@ Jump to a table: | [`POWERDNS`](powerdns.md) | ✅ | ✅ | ✅ | | [`REALTIMEREGISTER`](realtimeregister.md) | ✅ | ❔ | ❌ | | [`SAKURACLOUD`](sakuracloud.md) | ❌ | ❌ | ❌ | -| [`TENCENTDNS`](tencentdns.md) | ❌ | ❔ | ❔ | | [`TRANSIP`](transip.md) | ❌ | ❌ | ❌ | | [`VERCEL`](vercel.md) | ❌ | ❌ | ❌ | diff --git a/documentation/provider/tencentdns.md b/documentation/provider/tencentdns.md index 222dc68f38..a0b1cfe00d 100644 --- a/documentation/provider/tencentdns.md +++ b/documentation/provider/tencentdns.md @@ -4,7 +4,7 @@ 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/cns) (DNSPod). To use this provider, add an entry to `creds.json` with `TYPE` set to `TENCENTDNS` along with your API credentials. +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: @@ -14,7 +14,8 @@ Example: "tencentdns": { "TYPE": "TENCENTDNS", "secret_id": "YOUR_SECRET_ID", - "secret_key": "YOUR_SECRET_KEY" + "secret_key": "YOUR_SECRET_KEY", + "site": "cn | intl" } } ``` @@ -29,12 +30,35 @@ Optionally, you can specify a `region` (defaults to `"ap-guangzhou"`): "TYPE": "TENCENTDNS", "secret_id": "YOUR_SECRET_ID", "secret_key": "YOUR_SECRET_KEY", - "region": "ap-guangzhou" + "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: @@ -59,5 +83,6 @@ D("example.com", REG_TENCENT, DnsProvider(DSP_TENCENT), - **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 425b7ae5e0..4cb00d1cda 100644 --- a/go.mod +++ b/go.mod @@ -100,6 +100,7 @@ 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 diff --git a/go.sum b/go.sum index 2fdec6edcf..ab30e8950d 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,8 @@ 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= diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index f3a1df34e2..7a5c9cf6ad 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -355,9 +355,10 @@ }, "TENCENTDNS": { "TYPE": "TENCENTDNS", + "domain": "$TENCENTDNS_DOMAIN", "secret_id": "$TENCENTDNS_SECRET_ID", "secret_key": "$TENCENTDNS_SECRET_KEY", - "domain": "$TENCENTDNS_DOMAIN" + "site": "$TENCENTDNS_SITE" }, "TRANSIP": { "AccessToken": "$TRANSIP_ACCESS_TOKEN", diff --git a/providers/tencentdns/api.go b/providers/tencentdns/api.go index 9b70ae7ce1..c0f9bb29c6 100644 --- a/providers/tencentdns/api.go +++ b/providers/tencentdns/api.go @@ -2,38 +2,73 @@ 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 + dnspodClient *dnspod.Client + domainClient *domain.Client + intlDomainClient *intldomain.Client + useIntlDomainClient bool } -func newClient(secretId, secretKey, region string) (*tencentCloudClient, error) { +func newClient(secretId, secretKey, region, dnspodEndpoint string, useIntlDomainClient bool) (*tencentCloudClient, error) { credential := common.NewCredential(secretId, secretKey) - cpf := profile.NewClientProfile() - + + dnspodProfile := profile.NewClientProfile() + if dnspodEndpoint != "" { + dnspodProfile.HttpProfile.Endpoint = dnspodEndpoint + } + // DNSPod client - dpc, err := dnspod.NewClient(credential, region, cpf) + dpc, err := dnspod.NewClient(credential, region, dnspodProfile) if err != nil { return nil, fmt.Errorf("failed to create dnspod client: %w", err) } - // Domain client - dmc, err := domain.NewClient(credential, region, cpf) + 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 &tencentCloudClient{ - dnspodClient: dpc, - domainClient: dmc, - }, nil + return client, nil } func (c *tencentCloudClient) fetchRecords(domainName string) ([]*dnspod.RecordListItem, error) { @@ -43,9 +78,9 @@ func (c *tencentCloudClient) fetchRecords(domainName string) ([]*dnspod.RecordLi for { request := dnspod.NewDescribeRecordListRequest() - request.Domain = common.StringPtr(domainName) - request.Offset = common.Uint64Ptr(offset) - request.Limit = common.Uint64Ptr(limit) + request.Domain = new(domainName) + request.Offset = new(offset) + request.Limit = new(limit) response, err := c.dnspodClient.DescribeRecordList(request) if err != nil { @@ -65,7 +100,7 @@ func (c *tencentCloudClient) fetchRecords(domainName string) ([]*dnspod.RecordLi func (c *tencentCloudClient) getNameservers(domainName string) ([]string, error) { request := dnspod.NewDescribeDomainRequest() - request.Domain = common.StringPtr(domainName) + request.Domain = new(domainName) response, err := c.dnspodClient.DescribeDomain(request) if err != nil { @@ -79,9 +114,44 @@ func (c *tencentCloudClient) getNameservers(domainName string) ([]string, error) 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 = common.StringPtr(domainName) + request.Domain = new(domainName) response, err := c.dnspodClient.DescribeDomainWhois(request) if err != nil { @@ -96,30 +166,162 @@ func (c *tencentCloudClient) getRegistrarNameservers(domainName string) ([]strin } 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) - _, err := c.domainClient.ModifyDomainDNSBatch(request) - return err + 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 = common.StringPtr(domainName) + request.Domain = new(domainName) _, err := c.dnspodClient.CreateRecord(request) return err } func (c *tencentCloudClient) modifyRecord(domainName string, request *dnspod.ModifyRecordRequest) error { - request.Domain = common.StringPtr(domainName) + 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 = common.StringPtr(domainName) - request.RecordId = common.Uint64Ptr(recordId) + 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 index c8b0c13ab1..9a8768ba10 100644 --- a/providers/tencentdns/auditrecords.go +++ b/providers/tencentdns/auditrecords.go @@ -13,6 +13,8 @@ func AuditRecords(records []*models.RecordConfig) []error { 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 index 3344c4eaad..a1865eb168 100644 --- a/providers/tencentdns/convert.go +++ b/providers/tencentdns/convert.go @@ -18,7 +18,6 @@ func nativeToRecord(r *dnspod.RecordListItem, domainName string) (*models.Record val := *r.Value switch *r.Type { case "A", "AAAA", "CNAME", "NS", "PTR", "TXT", "CAA", "SRV": - // These are standard types, PopulateFromStringFunc handles them. case "MX": if r.MX != nil { val = fmt.Sprintf("%d %s", *r.MX, *r.Value) @@ -41,50 +40,53 @@ func nativeToRecord(r *dnspod.RecordListItem, domainName string) (*models.Record func recordToCreateRequest(rc *models.RecordConfig) *dnspod.CreateRecordRequest { req := dnspod.NewCreateRecordRequest() - req.SubDomain = commonStringPtr(rc.GetLabel()) - req.RecordType = commonStringPtr(rc.Type) + req.SubDomain = new(rc.GetLabel()) + req.RecordType = new(rc.Type) if rc.Type == "ALIAS" { - req.RecordType = commonStringPtr("CNAME") + req.RecordType = new("CNAME") } - req.RecordLine = commonStringPtr("默认") // Default line + req.RecordLine = new("默认") // Default line val := rc.GetTargetCombinedFunc(txtutil.EncodeQuoted) if rc.Type == "MX" { val = rc.GetTargetField() - req.MX = commonUint64Ptr(uint64(rc.MxPreference)) + req.MX = new(uint64(rc.MxPreference)) } - req.Value = commonStringPtr(val) - req.TTL = commonUint64Ptr(uint64(rc.TTL)) + 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 = commonUint64Ptr(recordId) - req.SubDomain = commonStringPtr(rc.GetLabel()) - req.RecordType = commonStringPtr(rc.Type) + req.RecordId = new(recordId) + req.SubDomain = new(rc.GetLabel()) + req.RecordType = new(rc.Type) if rc.Type == "ALIAS" { - req.RecordType = commonStringPtr("CNAME") + req.RecordType = new("CNAME") } - req.RecordLine = commonStringPtr("默认") + req.RecordLine = new("默认") val := rc.GetTargetCombinedFunc(txtutil.EncodeQuoted) if rc.Type == "MX" { val = rc.GetTargetField() - req.MX = commonUint64Ptr(uint64(rc.MxPreference)) + req.MX = new(uint64(rc.MxPreference)) } - req.Value = commonStringPtr(val) - req.TTL = commonUint64Ptr(uint64(rc.TTL)) + req.Value = new(val) + req.TTL = new(uint64(rc.TTL)) return req } // Helpers to avoid importing "common" in every file if possible, or just import it. +// +//go:fix inline func commonStringPtr(s string) *string { - return &s + return new(s) } +//go:fix inline func commonUint64Ptr(u uint64) *uint64 { - return &u + return new(u) } diff --git a/providers/tencentdns/convert_test.go b/providers/tencentdns/convert_test.go index 77b565f663..97a7b2d480 100644 --- a/providers/tencentdns/convert_test.go +++ b/providers/tencentdns/convert_test.go @@ -4,13 +4,13 @@ import ( "testing" "github.com/DNSControl/dnscontrol/v4/models" - dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" "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 @@ -19,9 +19,9 @@ func TestNativeToRecord(t *testing.T) { { name: "Basic A record", input: &dnspod.RecordListItem{ - Name: commonStringPtr("@"), - Type: commonStringPtr("A"), - Value: commonStringPtr("1.2.3.4"), + Name: new("@"), + Type: new("A"), + Value: new("1.2.3.4"), TTL: commonUint64Ptr(600), }, expected: &models.RecordConfig{ @@ -32,9 +32,9 @@ func TestNativeToRecord(t *testing.T) { { name: "CNAME record", input: &dnspod.RecordListItem{ - Name: commonStringPtr("www"), - Type: commonStringPtr("CNAME"), - Value: commonStringPtr("target.example.com."), + Name: new("www"), + Type: new("CNAME"), + Value: new("target.example.com."), TTL: commonUint64Ptr(300), }, expected: &models.RecordConfig{ @@ -45,9 +45,9 @@ func TestNativeToRecord(t *testing.T) { { name: "MX record", input: &dnspod.RecordListItem{ - Name: commonStringPtr("@"), - Type: commonStringPtr("MX"), - Value: commonStringPtr("mail.example.com."), + Name: new("@"), + Type: new("MX"), + Value: new("mail.example.com."), TTL: commonUint64Ptr(600), MX: commonUint64Ptr(10), }, diff --git a/providers/tencentdns/tencentdnsProvider.go b/providers/tencentdns/tencentdnsProvider.go index 508684b45b..9096ef27ce 100644 --- a/providers/tencentdns/tencentdnsProvider.go +++ b/providers/tencentdns/tencentdnsProvider.go @@ -12,10 +12,13 @@ import ( dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323" ) +const defaultTTL = uint32(600) + var features = providers.DocumentationNotes{ providers.CanUseAlias: providers.Can(), 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"), @@ -32,8 +35,42 @@ func init() { providers.RegisterDomainServiceProviderType(providerName, fns, features) providers.RegisterRegistrarType(providerName, newTencentDNSReg) providers.RegisterMaintainer(providerName, providerMaintainer) - // Default TTL for Tencent Cloud DNSPod is 600 for free domains. - providers.RegisterDefaultTTL(providerName, 600) + // 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 { @@ -60,7 +97,12 @@ func newTencentDNS(config map[string]string) (*tencentdnsProvider, error) { region = "ap-guangzhou" // Default region } - client, err := newClient(secretId, secretKey, region) + 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 } @@ -70,6 +112,25 @@ func newTencentDNS(config map[string]string) (*tencentdnsProvider, error) { }, 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. @@ -120,9 +181,23 @@ func (p *tencentdnsProvider) GetZoneRecords(dc *models.DomainConfig) (models.Rec 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 { @@ -172,14 +247,14 @@ func (p *tencentdnsProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([ if err != nil { return nil, err } - sort.Strings(actualSet) + actualSet = normalizeNameserverSet(actualSet) actual := strings.Join(actualSet, ",") expectedSet := []string{} for _, ns := range dc.Nameservers { expectedSet = append(expectedSet, ns.Name) } - sort.Strings(expectedSet) + expectedSet = normalizeNameserverSet(expectedSet) expected := strings.Join(expectedSet, ",") if actual != expected { @@ -196,6 +271,15 @@ func (p *tencentdnsProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([ 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 diff --git a/providers/tencentdns/tencentdnsProvider_test.go b/providers/tencentdns/tencentdnsProvider_test.go index 6c488ff3b1..6b365a62cc 100644 --- a/providers/tencentdns/tencentdnsProvider_test.go +++ b/providers/tencentdns/tencentdnsProvider_test.go @@ -3,7 +3,12 @@ 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) { @@ -17,6 +22,26 @@ func TestNewTencentDNS(t *testing.T) { 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) { @@ -29,3 +54,211 @@ func TestNewTencentDNS_MissingCreds(t *testing.T) { 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: commonUint64Ptr(600), + }, + { + DomainGrade: new("DP_Plus"), + MinTtl: commonUint64Ptr(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) +}