diff --git a/README.md b/README.md index 1ad4f4fb6..c02347e23 100644 --- a/README.md +++ b/README.md @@ -177,114 +177,119 @@ If your DNS provider is not supported, please open an [issue](https://github.com ISPConfig 3 - Dynamic DNS (DDNS) Module iwantmyname (Deprecated) + JD Cloud Joker Joohoi's ACME-DNS - KeyHelp + KeyHelp Liara Lima-City Linode (v4) - Liquid Web + Liquid Web Loopia LuaDNS Mail-in-a-Box - ManageEngine CloudDNS + ManageEngine CloudDNS Manual Metaname Metaregistrar - mijn.host + mijn.host Mittwald myaddr.{tools,dev,io} MyDNS.jp - MythicBeasts + MythicBeasts Name.com Namecheap Namesilo - NearlyFreeSpeech.NET + NearlyFreeSpeech.NET Neodigit Netcup Netlify - Nicmanager + Nicmanager NIFCloud Njalla Nodion - NS1 + NS1 Octenium Open Telekom Cloud Oracle Cloud - OVH + OVH plesk.com Porkbun PowerDNS - Rackspace + Rackspace Rain Yun/雨云 RcodeZero reg.ru - Regfish + Regfish RFC2136 RimuHosting RU CENTER - Sakura Cloud + Sakura Cloud Scaleway Selectel Selectel v2 - SelfHost.(de|eu) + SelfHost.(de|eu) Servercow Shellrent Simply.com - Sonic + Sonic Spaceship Stackpath Syse - Technitium + Technitium Tencent Cloud DNS Tencent EdgeOne Timeweb Cloud - TransIP + TransIP UKFast SafeDNS Ultradns United-Domains - Variomedia + Variomedia VegaDNS Vercel Versio.[nl|eu|uk] - VinylDNS + VinylDNS Virtualname VK Cloud Volcano Engine/火山引擎 - Vscale + Vscale Vultr webnames.ca webnames.ru - Websupport + Websupport WEDOS West.cn/西部数码 Yandex 360 - Yandex Cloud + Yandex Cloud Yandex PDD Zone.ee ZoneEdit + Zonomi + + + diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 44fec8e54..a918a1484 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -105,6 +105,7 @@ func allDNSCodes() string { "ispconfig", "ispconfigddns", "iwantmyname", + "jdcloud", "joker", "keyhelp", "liara", @@ -2195,6 +2196,28 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iwantmyname`) + case "jdcloud": + // generated from: providers/dns/jdcloud/jdcloud.toml + ew.writeln(`Configuration for JD Cloud.`) + ew.writeln(`Code: 'jdcloud'`) + ew.writeln(`Since: 'v4.31.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "JDCLOUD_ACCESS_KEY_ID": Access key ID`) + ew.writeln(` - "JDCLOUD_ACCESS_KEY_SECRET": Access key secret`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "JDCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "JDCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "JDCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "JDCLOUD_REGION_ID": Region ID (Default: cn-north-1)`) + ew.writeln(` - "JDCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/jdcloud`) + case "joker": // generated from: providers/dns/joker/joker.toml ew.writeln(`Configuration for Joker.`) diff --git a/docs/content/dns/zz_gen_jdcloud.md b/docs/content/dns/zz_gen_jdcloud.md new file mode 100644 index 000000000..a37cc3520 --- /dev/null +++ b/docs/content/dns/zz_gen_jdcloud.md @@ -0,0 +1,71 @@ +--- +title: "JD Cloud" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: jdcloud +dnsprovider: + since: "v4.31.0" + code: "jdcloud" + url: "https://www.jdcloud.com/" +--- + + + + + + +Configuration for [JD Cloud](https://www.jdcloud.com/). + + + + +- Code: `jdcloud` +- Since: v4.31.0 + + +Here is an example bash command using the JD Cloud provider: + +```bash +JDCLOUD_ACCESS_KEY_ID="xxx" \ +JDCLOUD_ACCESS_KEY_SECRET="yyy" \ +lego --dns jdcloud -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `JDCLOUD_ACCESS_KEY_ID` | Access key ID | +| `JDCLOUD_ACCESS_KEY_SECRET` | Access key secret | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `JDCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `JDCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `JDCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `JDCLOUD_REGION_ID` | Region ID (Default: cn-north-1) | +| `JDCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + + + +## More information + +- [API documentation](https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview) +- [Go client](https://github.com/jdcloud-api/jdcloud-sdk-go) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 2ee3e9006..7d1a4b2c3 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/go.mod b/go.mod index dd1a5b4b7..59da5e349 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/exoscale/egoscale/v3 v3.1.31 github.com/go-acme/alidns-20150109/v4 v4.7.0 github.com/go-acme/esa-20240910/v2 v2.40.3 + github.com/go-acme/jdcloud-sdk-go v1.64.0 github.com/go-acme/tencentclouddnspod v1.1.25 github.com/go-acme/tencentedgdeone v1.1.48 github.com/go-jose/go-jose/v4 v4.1.3 @@ -154,6 +155,7 @@ require ( github.com/go-resty/resty/v2 v2.17.0 // indirect github.com/goccy/go-yaml v1.9.8 // indirect github.com/gofrs/flock v0.13.0 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/go.sum b/go.sum index 5e63fdba3..993e97c7d 100644 --- a/go.sum +++ b/go.sum @@ -317,6 +317,8 @@ github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQL github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0= github.com/go-acme/esa-20240910/v2 v2.40.3 h1:xXOMRex148wKEHbv7Izn73/HdAxSmz5GOaF4HdnqN+M= github.com/go-acme/esa-20240910/v2 v2.40.3/go.mod h1:ZYdN9EN9ikn26SNapxCVjZ65pHT/1qm4fzuJ7QGVX6g= +github.com/go-acme/jdcloud-sdk-go v1.64.0 h1:AW9j5khk8tRYbpBJPxKmqdwIqgLs2Fz3HUK3hn2YXjs= +github.com/go-acme/jdcloud-sdk-go v1.64.0/go.mod h1:qc/m8HNX1Zgd7GAv2DSEinup8fwy3Ted3/VVx7LB5bU= github.com/go-acme/tencentclouddnspod v1.1.25 h1:7H3ZKshkaHzCXfRpAHVB5nvxeDDl2XLeNZfrNHiZj/s= github.com/go-acme/tencentclouddnspod v1.1.25/go.mod h1:XXfzp0AYV7UAUsHKT6R0KAUJFhqAUXmWGF07Elpa5cE= github.com/go-acme/tencentedgdeone v1.1.48 h1:WLyLBsRVhSLFmtbEFXk0naLODSQn7X6J0Fc/qR8xVUk= @@ -374,6 +376,8 @@ github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXK github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= diff --git a/providers/dns/jdcloud/fixtures/create_record-request.json b/providers/dns/jdcloud/fixtures/create_record-request.json new file mode 100644 index 000000000..581c00fea --- /dev/null +++ b/providers/dns/jdcloud/fixtures/create_record-request.json @@ -0,0 +1,15 @@ +{ + "domainId": "20", + "regionId": "cn-north-1", + "req": { + "hostRecord": "_acme-challenge", + "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "jcloudRes": null, + "mxPriority": null, + "port": null, + "ttl": 120, + "type": "TXT", + "viewValue": -1, + "weight": null + } +} diff --git a/providers/dns/jdcloud/fixtures/create_record.json b/providers/dns/jdcloud/fixtures/create_record.json new file mode 100644 index 000000000..08bd3db26 --- /dev/null +++ b/providers/dns/jdcloud/fixtures/create_record.json @@ -0,0 +1,25 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": { + "dataList": { + "id": 123, + "hostRecord": "_acme-challenge", + "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "jcloudRes": false, + "mxPriority": 0, + "port": 0, + "ttl": 120, + "type": "TXT", + "weight": 0, + "viewValue": [ + 1, + 2 + ] + } + } +} diff --git a/providers/dns/jdcloud/fixtures/delete_record.json b/providers/dns/jdcloud/fixtures/delete_record.json new file mode 100644 index 000000000..20525751c --- /dev/null +++ b/providers/dns/jdcloud/fixtures/delete_record.json @@ -0,0 +1,9 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": {} +} diff --git a/providers/dns/jdcloud/fixtures/describe_domains_page1.json b/providers/dns/jdcloud/fixtures/describe_domains_page1.json new file mode 100644 index 000000000..cde6dcd6f --- /dev/null +++ b/providers/dns/jdcloud/fixtures/describe_domains_page1.json @@ -0,0 +1,55 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": { + "dataList": [ + { + "id": 1, + "domainName": "1.example" + }, + { + "id": 2, + "domainName": "2.example" + }, + { + "id": 3, + "domainName": "3.example" + }, + { + "id": 4, + "domainName": "4.example" + }, + { + "id": 5, + "domainName": "5.example" + }, + { + "id": 6, + "domainName": "6.example" + }, + { + "id": 7, + "domainName": "7.example" + }, + { + "id": 8, + "domainName": "8.example" + }, + { + "id": 9, + "domainName": "9.example" + }, + { + "id": 10, + "domainName": "10.example" + } + ], + "currentCount": 10, + "totalCount": 20, + "totalPage": 2 + } +} diff --git a/providers/dns/jdcloud/fixtures/describe_domains_page2.json b/providers/dns/jdcloud/fixtures/describe_domains_page2.json new file mode 100644 index 000000000..b1e1560ab --- /dev/null +++ b/providers/dns/jdcloud/fixtures/describe_domains_page2.json @@ -0,0 +1,55 @@ +{ + "requestId": "azerty", + "error": { + "code": 0, + "status": "", + "message": "" + }, + "result": { + "dataList": [ + { + "id": 11, + "domainName": "11.example" + }, + { + "id": 12, + "domainName": "12.example" + }, + { + "id": 13, + "domainName": "13.example" + }, + { + "id": 14, + "domainName": "14.example" + }, + { + "id": 15, + "domainName": "15.example" + }, + { + "id": 16, + "domainName": "16.example" + }, + { + "id": 17, + "domainName": "17.example" + }, + { + "id": 18, + "domainName": "18.example" + }, + { + "id": 19, + "domainName": "19.example" + }, + { + "id": 20, + "domainName": "example.com" + } + ], + "currentCount": 10, + "totalCount": 20, + "totalPage": 2 + } +} diff --git a/providers/dns/jdcloud/jdcloud.go b/providers/dns/jdcloud/jdcloud.go new file mode 100644 index 000000000..7d9ad4e6b --- /dev/null +++ b/providers/dns/jdcloud/jdcloud.go @@ -0,0 +1,217 @@ +// Package jdcloud implements a DNS provider for solving the DNS-01 challenge using JD Cloud. +package jdcloud + +import ( + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/go-acme/jdcloud-sdk-go/core" + "github.com/go-acme/jdcloud-sdk-go/services/domainservice/apis" + jdcclient "github.com/go-acme/jdcloud-sdk-go/services/domainservice/client" + domainservice "github.com/go-acme/jdcloud-sdk-go/services/domainservice/models" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" +) + +// Environment variables names. +const ( + envNamespace = "JDCLOUD_" + + EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" + EnvAccessKeySecret = envNamespace + "ACCESS_KEY_SECRET" + EnvRegionID = envNamespace + "REGION_ID" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + AccessKeyID string + AccessKeySecret string + RegionID string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *jdcclient.DomainserviceClient + + recordIDs map[string]int + domainIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for JD Cloud. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccessKeyID, EnvAccessKeySecret) + if err != nil { + return nil, fmt.Errorf("jdcloud: %w", err) + } + + config := NewDefaultConfig() + config.AccessKeyID = values[EnvAccessKeyID] + config.AccessKeySecret = values[EnvAccessKeySecret] + + // https://docs.jdcloud.com/en/common-declaration/api/introduction#Region%20Code + config.RegionID = env.GetOrDefaultString(EnvRegionID, "cn-north-1") + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for JD Cloud. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("jdcloud: the configuration of the DNS provider is nil") + } + + if config.AccessKeyID == "" || config.AccessKeySecret == "" { + return nil, errors.New("jdcloud: missing credentials") + } + + cred := core.NewCredentials(config.AccessKeyID, config.AccessKeySecret) + + client := jdcclient.NewDomainserviceClient(cred) + client.DisableLogger() + client.Config.SetTimeout(config.HTTPTimeout) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int), + domainIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("jdcloud: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("jdcloud: %w", err) + } + + zone, err := d.findZone(dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("jdcloud: %w", err) + } + + // https://docs.jdcloud.com/cn/jd-cloud-dns/api/createresourcerecord + crrr := apis.NewCreateResourceRecordRequestWithAllParams( + d.config.RegionID, + strconv.Itoa(zone.Id), + &domainservice.AddRR{ + HostRecord: subDomain, + HostValue: info.Value, + Ttl: d.config.TTL, + Type: "TXT", + ViewValue: -1, + }, + ) + + record, err := jdcclient.CreateResourceRecord(d.client, crrr) + if err != nil { + return fmt.Errorf("jdcloud: create resource record: %w", err) + } + + d.recordIDsMu.Lock() + d.domainIDs[token] = zone.Id + d.recordIDs[token] = record.Result.DataList.Id + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + recordID, recordOK := d.recordIDs[token] + domainID, domainOK := d.domainIDs[token] + d.recordIDsMu.Unlock() + + if !recordOK { + return fmt.Errorf("jdcloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + if !domainOK { + return fmt.Errorf("jdcloud: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + // https://docs.jdcloud.com/cn/jd-cloud-dns/api/deleteresourcerecord + drrr := apis.NewDeleteResourceRecordRequestWithAllParams( + d.config.RegionID, + strconv.Itoa(domainID), + strconv.Itoa(recordID), + ) + + _, err := jdcclient.DeleteResourceRecord(d.client, drrr) + if err != nil { + return fmt.Errorf("jdcloud: delete resource record: %w", err) + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(zone string) (*domainservice.DomainInfo, error) { + // https://docs.jdcloud.com/cn/jd-cloud-dns/api/describedomains + ddr := apis.NewDescribeDomainsRequestWithoutParam() + ddr.SetRegionId(d.config.RegionID) + ddr.SetPageNumber(1) + ddr.SetPageSize(10) + ddr.SetDomainName(zone) + + for { + response, err := jdcclient.DescribeDomains(d.client, ddr) + if err != nil { + return nil, fmt.Errorf("describe domains: %w", err) + } + + for _, d := range response.Result.DataList { + if d.DomainName == zone { + return &d, nil + } + } + + if len(response.Result.DataList) < ddr.PageSize || response.Result.TotalPage <= ddr.PageNumber { + break + } + + ddr.SetPageNumber(ddr.PageNumber + 1) + } + + return nil, errors.New("zone not found") +} diff --git a/providers/dns/jdcloud/jdcloud.toml b/providers/dns/jdcloud/jdcloud.toml new file mode 100644 index 000000000..7ab403822 --- /dev/null +++ b/providers/dns/jdcloud/jdcloud.toml @@ -0,0 +1,27 @@ +Name = "JD Cloud" +Description = '''''' +URL = "https://www.jdcloud.com/" +Code = "jdcloud" +Since = "v4.31.0" + +Example = ''' +JDCLOUD_ACCESS_KEY_ID="xxx" \ +JDCLOUD_ACCESS_KEY_SECRET="yyy" \ +lego --dns jdcloud -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + JDCLOUD_ACCESS_KEY_ID = "Access key ID" + JDCLOUD_ACCESS_KEY_SECRET = "Access key secret" + [Configuration.Additional] + JDCLOUD_REGION_ID = "Region ID (Default: cn-north-1)" + JDCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + JDCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + JDCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + JDCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview" + Common = "https://docs.jdcloud.com/en/common-declaration/api/introduction" + GoClient = "https://github.com/jdcloud-api/jdcloud-sdk-go" diff --git a/providers/dns/jdcloud/jdcloud_test.go b/providers/dns/jdcloud/jdcloud_test.go new file mode 100644 index 000000000..6b3368938 --- /dev/null +++ b/providers/dns/jdcloud/jdcloud_test.go @@ -0,0 +1,242 @@ +package jdcloud + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAccessKeyID, + EnvAccessKeySecret, + EnvRegionID, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccessKeyID: "abc123", + EnvAccessKeySecret: "secret", + }, + }, + { + desc: "missing access key ID", + envVars: map[string]string{ + EnvAccessKeyID: "", + EnvAccessKeySecret: "secret", + }, + expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID", + }, + { + desc: "missing access key secret", + envVars: map[string]string{ + EnvAccessKeyID: "abc123", + EnvAccessKeySecret: "", + }, + expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID,JDCLOUD_ACCESS_KEY_SECRET", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + accessKeyID string + accessKeySecret string + expected string + }{ + { + desc: "success", + accessKeyID: "abc123", + accessKeySecret: "secret", + }, + { + desc: "missing access key ID", + accessKeySecret: "secret", + expected: "jdcloud: missing credentials", + }, + { + desc: "missing access key secret", + accessKeyID: "abc123", + expected: "jdcloud: missing credentials", + }, + { + desc: "missing credentials", + expected: "jdcloud: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.AccessKeyID = test.accessKeyID + config.AccessKeySecret = test.accessKeySecret + config.RegionID = "cn-north-1" + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AccessKeyID = "abc123" + config.AccessKeySecret = "secret" + config.RegionID = "cn-north-1" + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + serverURL, _ := url.Parse(server.URL) + + p.client.Config.SetEndpoint(net.JoinHostPort(serverURL.Hostname(), serverURL.Port())) + p.client.Config.SetScheme(serverURL.Scheme) + p.client.Config.SetTimeout(server.Client().Timeout) + + return p, nil + }, + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /v2/regions/cn-north-1/domain", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + pageNumber := req.URL.Query().Get("pageNumber") + + servermock.ResponseFromFixture( + fmt.Sprintf("describe_domains_page%s.json", pageNumber), + ).ServeHTTP(rw, req) + }), + servermock.CheckQueryParameter().Strict(). + With("domainName", "example.com"). + WithRegexp("pageNumber", `(1|2)`). + With("pageSize", "10"), + servermock.CheckHeader(). + WithRegexp("Authorization", + `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). + WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). + WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), + ). + Route("POST /v2/regions/cn-north-1/domain/20/ResourceRecord", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"), + servermock.CheckHeader(). + WithRegexp("Authorization", + `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). + WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). + WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) + + require.Len(t, provider.domainIDs, 1) + require.Len(t, provider.recordIDs, 1) + + assert.Equal(t, 20, provider.domainIDs["abc"]) + assert.Equal(t, 123, provider.recordIDs["abc"]) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("DELETE /v2/regions/cn-north-1/domain/20/ResourceRecord/123", + servermock.ResponseFromFixture("delete_record.json"), + servermock.CheckHeader(). + WithRegexp("Authorization", + `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). + WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). + WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), + ). + Build(t) + + provider.domainIDs["abc"] = 20 + provider.recordIDs["abc"] = 123 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index 0d9ad26e8..7099a3f4c 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -99,6 +99,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/ispconfig" "github.com/go-acme/lego/v4/providers/dns/ispconfigddns" "github.com/go-acme/lego/v4/providers/dns/iwantmyname" + "github.com/go-acme/lego/v4/providers/dns/jdcloud" "github.com/go-acme/lego/v4/providers/dns/joker" "github.com/go-acme/lego/v4/providers/dns/keyhelp" "github.com/go-acme/lego/v4/providers/dns/liara" @@ -377,6 +378,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return ispconfigddns.NewDNSProvider() case "iwantmyname": return iwantmyname.NewDNSProvider() + case "jdcloud": + return jdcloud.NewDNSProvider() case "joker": return joker.NewDNSProvider() case "keyhelp":