Add DNS provider for CPanel and WHM (#1977)

This commit is contained in:
Ludovic Fernandez 2024-02-04 19:43:54 +01:00 committed by GitHub
parent 719adc3964
commit 83ff393131
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1778 additions and 28 deletions

View file

@ -237,3 +237,5 @@ issues:
text: 'Duplicate words \(0\) found'
- path: cmd/cmd_renew.go
text: 'cyclomatic complexity 15 of func `renewForDomains` is high'
- path: providers/dns/cpanel/cpanel.go
text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high'

View file

@ -58,33 +58,33 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
| [Azure (deprecated)](https://go-acme.github.io/lego/dns/azure/) | [Azure DNS](https://go-acme.github.io/lego/dns/azuredns/) | [Bindman](https://go-acme.github.io/lego/dns/bindman/) | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/) |
| [Brandit](https://go-acme.github.io/lego/dns/brandit/) | [Bunny](https://go-acme.github.io/lego/dns/bunny/) | [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/) | [Civo](https://go-acme.github.io/lego/dns/civo/) |
| [Cloud.ru](https://go-acme.github.io/lego/dns/cloudru/) | [CloudDNS](https://go-acme.github.io/lego/dns/clouddns/) | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/) |
| [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/) | [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Constellix](https://go-acme.github.io/lego/dns/constellix/) | [Derak Cloud](https://go-acme.github.io/lego/dns/derak/) |
| [deSEC.io](https://go-acme.github.io/lego/dns/desec/) | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) | [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) |
| [dnsHome.de](https://go-acme.github.io/lego/dns/dnshomede/) | [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod (deprecated)](https://go-acme.github.io/lego/dns/dnspod/) | [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) |
| [Domeneshop](https://go-acme.github.io/lego/dns/domeneshop/) | [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) | [Dyn](https://go-acme.github.io/lego/dns/dyn/) |
| [Dynu](https://go-acme.github.io/lego/dns/dynu/) | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Efficient IP](https://go-acme.github.io/lego/dns/efficientip/) | [Epik](https://go-acme.github.io/lego/dns/epik/) |
| [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [freemyip.com](https://go-acme.github.io/lego/dns/freemyip/) | [G-Core](https://go-acme.github.io/lego/dns/gcore/) |
| [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) |
| [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Google Domains](https://go-acme.github.io/lego/dns/googledomains/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) |
| [Hosttech](https://go-acme.github.io/lego/dns/hosttech/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [http.net](https://go-acme.github.io/lego/dns/httpnet/) | [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) |
| [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [IBM Cloud (SoftLayer)](https://go-acme.github.io/lego/dns/ibmcloud/) | [IIJ DNS Platform Service](https://go-acme.github.io/lego/dns/iijdpf/) | [Infoblox](https://go-acme.github.io/lego/dns/infoblox/) |
| [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) |
| [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [IPv64](https://go-acme.github.io/lego/dns/ipv64/) | [iwantmyname](https://go-acme.github.io/lego/dns/iwantmyname/) | [Joker](https://go-acme.github.io/lego/dns/joker/) |
| [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Liara](https://go-acme.github.io/lego/dns/liara/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) |
| [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [Metaname](https://go-acme.github.io/lego/dns/metaname/) |
| [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) |
| [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [NearlyFreeSpeech.NET](https://go-acme.github.io/lego/dns/nearlyfreespeech/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) |
| [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [Nodion](https://go-acme.github.io/lego/dns/nodion/) |
| [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) |
| [plesk.com](https://go-acme.github.io/lego/dns/plesk/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) |
| [RcodeZero](https://go-acme.github.io/lego/dns/rcodezero/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) |
| [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) |
| [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) |
| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) | [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) |
| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) |
| [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Webnames](https://go-acme.github.io/lego/dns/webnames/) |
| [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex 360](https://go-acme.github.io/lego/dns/yandex360/) | [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) |
| [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | |
| [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/) | [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Constellix](https://go-acme.github.io/lego/dns/constellix/) | [CPanel/WHM](https://go-acme.github.io/lego/dns/cpanel/) |
| [Derak Cloud](https://go-acme.github.io/lego/dns/derak/) | [deSEC.io](https://go-acme.github.io/lego/dns/desec/) | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) |
| [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) | [dnsHome.de](https://go-acme.github.io/lego/dns/dnshomede/) | [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod (deprecated)](https://go-acme.github.io/lego/dns/dnspod/) |
| [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) | [Domeneshop](https://go-acme.github.io/lego/dns/domeneshop/) | [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) |
| [Dyn](https://go-acme.github.io/lego/dns/dyn/) | [Dynu](https://go-acme.github.io/lego/dns/dynu/) | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Efficient IP](https://go-acme.github.io/lego/dns/efficientip/) |
| [Epik](https://go-acme.github.io/lego/dns/epik/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [freemyip.com](https://go-acme.github.io/lego/dns/freemyip/) |
| [G-Core](https://go-acme.github.io/lego/dns/gcore/) | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) |
| [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Google Domains](https://go-acme.github.io/lego/dns/googledomains/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) |
| [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [Hosttech](https://go-acme.github.io/lego/dns/hosttech/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [http.net](https://go-acme.github.io/lego/dns/httpnet/) |
| [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [IBM Cloud (SoftLayer)](https://go-acme.github.io/lego/dns/ibmcloud/) | [IIJ DNS Platform Service](https://go-acme.github.io/lego/dns/iijdpf/) |
| [Infoblox](https://go-acme.github.io/lego/dns/infoblox/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/) |
| [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [IPv64](https://go-acme.github.io/lego/dns/ipv64/) | [iwantmyname](https://go-acme.github.io/lego/dns/iwantmyname/) |
| [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Liara](https://go-acme.github.io/lego/dns/liara/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) |
| [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) |
| [Metaname](https://go-acme.github.io/lego/dns/metaname/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) |
| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [NearlyFreeSpeech.NET](https://go-acme.github.io/lego/dns/nearlyfreespeech/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) |
| [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) |
| [Nodion](https://go-acme.github.io/lego/dns/nodion/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) |
| [OVH](https://go-acme.github.io/lego/dns/ovh/) | [plesk.com](https://go-acme.github.io/lego/dns/plesk/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) |
| [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RcodeZero](https://go-acme.github.io/lego/dns/rcodezero/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) |
| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) |
| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) |
| [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) |
| [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) |
| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) |
| [Webnames](https://go-acme.github.io/lego/dns/webnames/) | [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex 360](https://go-acme.github.io/lego/dns/yandex360/) |
| [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) |
<!-- END DNS PROVIDERS LIST -->

View file

@ -35,6 +35,7 @@ func allDNSCodes() string {
"cloudxns",
"conoha",
"constellix",
"cpanel",
"derak",
"desec",
"designate",
@ -611,6 +612,31 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/constellix`)
case "cpanel":
// generated from: providers/dns/cpanel/cpanel.toml
ew.writeln(`Configuration for CPanel/WHM.`)
ew.writeln(`Code: 'cpanel'`)
ew.writeln(`Since: 'v4.16.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "CPANEL_BASE_URL": API server URL`)
ew.writeln(` - "CPANEL_NAMESERVER": Nameserver`)
ew.writeln(` - "CPANEL_TOKEN": API token`)
ew.writeln(` - "CPANEL_USERNAME": username`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "CPANEL_HTTP_TIMEOUT": API request timeout`)
ew.writeln(` - "CPANEL_MODE": use cpanel API or WHM API (Default: cpanel)`)
ew.writeln(` - "CPANEL_POLLING_INTERVAL": Time between DNS propagation check`)
ew.writeln(` - "CPANEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
ew.writeln(` - "CPANEL_REGION": The region`)
ew.writeln(` - "CPANEL_TTL": The TTL of the TXT record used for the DNS challenge`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cpanel`)
case "derak":
// generated from: providers/dns/derak/derak.toml
ew.writeln(`Configuration for Derak Cloud.`)

View file

@ -0,0 +1,86 @@
---
title: "CPanel/WHM"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: cpanel
dnsprovider:
since: "v4.16.0"
code: "cpanel"
url: "https://cpanel.net/"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/cpanel/cpanel.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [CPanel/WHM](https://cpanel.net/).
<!--more-->
- Code: `cpanel`
- Since: v4.16.0
Here is an example bash command using the CPanel/WHM provider:
```bash
### CPANEL (default)
CPANEL_USERNAME = "yyyy"
CPANEL_TOKEN = "xxxx"
CPANEL_BASE_URL = "https://example.com:2083" \
CPANEL_NAMESERVER = "ns1.example.com:53" \
lego --email you@example.com --dns cpanel --domains my.example.org run
## WHM
CPANEL_MODE = whm
CPANEL_USERNAME = "yyyy"
CPANEL_TOKEN = "xxxx"
CPANEL_BASE_URL = "https://example.com:2087" \
CPANEL_NAMESERVER = "ns1.example.com:53" \
lego --email you@example.com --dns cpanel --domains my.example.org run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `CPANEL_BASE_URL` | API server URL |
| `CPANEL_NAMESERVER` | Nameserver |
| `CPANEL_TOKEN` | API token |
| `CPANEL_USERNAME` | username |
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 |
|--------------------------------|-------------|
| `CPANEL_HTTP_TIMEOUT` | API request timeout |
| `CPANEL_MODE` | use cpanel API or WHM API (Default: cpanel) |
| `CPANEL_POLLING_INTERVAL` | Time between DNS propagation check |
| `CPANEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
| `CPANEL_REGION` | The region |
| `CPANEL_TTL` | The TTL of the TXT record used for the DNS challenge |
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
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/cpanel/cpanel.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -137,7 +137,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, derak, desec, designate, digitalocean, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, linode, liquidweb, loopia, luadns, manual, metaname, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, servercow, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi
acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, cpanel, derak, desec, designate, digitalocean, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, linode, liquidweb, loopia, luadns, manual, metaname, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, servercow, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""

View file

@ -0,0 +1,346 @@
// Package cpanel implements a DNS provider for solving the DNS-01 challenge using CPanel.
package cpanel
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/cpanel"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/whm"
)
// Environment variables names.
const (
envNamespace = "CPANEL_"
EnvMode = envNamespace + "MODE"
EnvUsername = envNamespace + "USERNAME"
EnvToken = envNamespace + "TOKEN"
EnvBaseURL = envNamespace + "BASE_URL"
EnvNameserver = envNamespace + "NAMESERVER"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
type apiClient interface {
FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error)
AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error)
EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error)
DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error)
}
// Config is used to configure the creation of the DNSProvider.
type Config struct {
Mode string
Username string
Token string
BaseURL string
Nameserver string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
Mode: env.GetOrDefaultString(EnvMode, "cpanel"),
TTL: env.GetOrDefaultInt(EnvTTL, 300),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
client apiClient
dnsClient *shared.DNSClient
}
// NewDNSProvider returns a DNSProvider instance configured for CPanel.
// Credentials must be passed in the environment variables:
// CPANEL_USERNAME, CPANEL_TOKEN, CPANEL_BASE_URL, CPANEL_NAMESERVER.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvUsername, EnvToken, EnvBaseURL, EnvNameserver)
if err != nil {
return nil, fmt.Errorf("cpanel: %w", err)
}
config := NewDefaultConfig()
config.Username = values[EnvUsername]
config.Token = values[EnvToken]
config.BaseURL = values[EnvBaseURL]
config.Nameserver = values[EnvNameserver]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for CPanel.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("cpanel: the configuration of the DNS provider is nil")
}
if config.Username == "" || config.Token == "" {
return nil, errors.New("cpanel: some credentials information are missing")
}
if config.BaseURL == "" || config.Nameserver == "" {
return nil, errors.New("cpanel: server information are missing")
}
client, err := createClient(config)
if err != nil {
return nil, fmt.Errorf("cpanel: create client error: %w", err)
}
return &DNSProvider{
config: config,
client: client,
dnsClient: shared.NewDNSClient(10 * time.Second),
}, 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
}
// Present creates a TXT record to fulfill the dns-01 challenge.
func (d *DNSProvider) Present(domain, _, keyAuth string) error {
ctx := context.Background()
info := dns01.GetChallengeInfo(domain, keyAuth)
effectiveDomain := strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge.")
soa, err := d.dnsClient.SOACall(effectiveDomain, d.config.Nameserver)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: could not find SOA for domain %q (%s) in %s: %w", d.config.Mode, domain, info.EffectiveFQDN, d.config.Nameserver, err)
}
zone := dns01.UnFqdn(soa.Hdr.Name)
zoneInfo, err := d.client.FetchZoneInformation(ctx, zone)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err)
}
serial, err := getZoneSerial(soa.Hdr.Name, zoneInfo)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err)
}
valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value))
var found bool
var existingRecord shared.ZoneRecord
for _, record := range zoneInfo {
if contains(record.DataB64, valueB64) {
existingRecord = record
found = true
break
}
}
record := shared.Record{
DName: info.EffectiveFQDN,
TTL: d.config.TTL,
RecordType: "TXT",
}
// New record.
if !found {
record.Data = []string{info.Value}
_, err = d.client.AddRecord(ctx, serial, zone, record)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: add record: %w", d.config.Mode, err)
}
return nil
}
// Update existing record.
record.LineIndex = existingRecord.LineIndex
for _, dataB64 := range existingRecord.DataB64 {
data, errD := base64.StdEncoding.DecodeString(dataB64)
if errD != nil {
return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD)
}
record.Data = append(record.Data, string(data))
}
record.Data = append(record.Data, info.Value)
_, err = d.client.EditRecord(ctx, serial, zone, record)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
ctx := context.Background()
info := dns01.GetChallengeInfo(domain, keyAuth)
soa, err := d.dnsClient.SOACall(strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge."), d.config.Nameserver)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: could not find SOA for domain %q (%s) in %s: %w", d.config.Mode, domain, info.EffectiveFQDN, d.config.Nameserver, err)
}
zone := dns01.UnFqdn(soa.Hdr.Name)
zoneInfo, err := d.client.FetchZoneInformation(ctx, zone)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err)
}
serial, err := getZoneSerial(soa.Hdr.Name, zoneInfo)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err)
}
valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value))
var found bool
var existingRecord shared.ZoneRecord
for _, record := range zoneInfo {
if contains(record.DataB64, valueB64) {
existingRecord = record
found = true
break
}
}
if !found {
return nil
}
var newData []string
for _, dataB64 := range existingRecord.DataB64 {
if dataB64 == valueB64 {
continue
}
data, errD := base64.StdEncoding.DecodeString(dataB64)
if errD != nil {
return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD)
}
newData = append(newData, string(data))
}
// Delete record.
if len(newData) == 0 {
_, err = d.client.DeleteRecord(ctx, serial, zone, existingRecord.LineIndex)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: delete record: %w", d.config.Mode, err)
}
return nil
}
// Remove one value.
record := shared.Record{
DName: info.EffectiveFQDN,
TTL: d.config.TTL,
RecordType: "TXT",
Data: newData,
LineIndex: existingRecord.LineIndex,
}
_, err = d.client.EditRecord(ctx, serial, zone, record)
if err != nil {
return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err)
}
return nil
}
func getZoneSerial(zoneFqdn string, zoneInfo []shared.ZoneRecord) (uint32, error) {
nameB64 := base64.StdEncoding.EncodeToString([]byte(zoneFqdn))
for _, record := range zoneInfo {
if record.Type != "record" || record.RecordType != "SOA" || record.DNameB64 != nameB64 {
continue
}
// https://github.com/go-acme/lego/issues/1060#issuecomment-1925572386
// https://github.com/go-acme/lego/issues/1060#issuecomment-1925581832
data, err := base64.StdEncoding.DecodeString(record.DataB64[2])
if err != nil {
return 0, fmt.Errorf("decode serial DNameB64: %w", err)
}
var newSerial uint32
_, err = fmt.Sscan(string(data), &newSerial)
if err != nil {
return 0, fmt.Errorf("decode serial DNameB64, invalid serial value %q: %w", string(data), err)
}
return newSerial, nil
}
return 0, errors.New("zone serial not found")
}
func createClient(config *Config) (apiClient, error) {
switch strings.ToLower(config.Mode) {
case "cpanel":
client, err := cpanel.NewClient(config.BaseURL, config.Username, config.Token)
if err != nil {
return nil, fmt.Errorf("failed to create cPanel API client: %w", err)
}
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
return client, nil
case "whm":
client, err := whm.NewClient(config.BaseURL, config.Username, config.Token)
if err != nil {
return nil, fmt.Errorf("failed to create WHM API client: %w", err)
}
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
return client, nil
default:
return nil, fmt.Errorf("unsupported mode: %q", config.Mode)
}
}
func contains(values []string, value string) bool {
for _, v := range values {
if v == value {
return true
}
}
return false
}

View file

@ -0,0 +1,42 @@
Name = "CPanel/WHM"
Description = ''''''
URL = "https://cpanel.net/"
Code = "cpanel"
Since = "v4.16.0"
Example = '''
### CPANEL (default)
CPANEL_USERNAME = "yyyy"
CPANEL_TOKEN = "xxxx"
CPANEL_BASE_URL = "https://example.com:2083" \
CPANEL_NAMESERVER = "ns1.example.com:53" \
lego --email you@example.com --dns cpanel --domains my.example.org run
## WHM
CPANEL_MODE = whm
CPANEL_USERNAME = "yyyy"
CPANEL_TOKEN = "xxxx"
CPANEL_BASE_URL = "https://example.com:2087" \
CPANEL_NAMESERVER = "ns1.example.com:53" \
lego --email you@example.com --dns cpanel --domains my.example.org run
'''
[Configuration]
[Configuration.Credentials]
CPANEL_USERNAME = "username"
CPANEL_TOKEN = "API token"
CPANEL_BASE_URL = "API server URL"
CPANEL_NAMESERVER = "Nameserver"
[Configuration.Additional]
CPANEL_MODE = "use cpanel API or WHM API (Default: cpanel)"
CPANEL_POLLING_INTERVAL = "Time between DNS propagation check"
CPANEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
CPANEL_TTL = "The TTL of the TXT record used for the DNS challenge"
CPANEL_HTTP_TIMEOUT = "API request timeout"
CPANEL_REGION = "The region"
[Links]
API_CPANEL = "https://api.docs.cpanel.net/cpanel/introduction/"
API_WHM = "https://api.docs.cpanel.net/whm/introduction/"

View file

@ -0,0 +1,338 @@
package cpanel
import (
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(
EnvMode,
EnvUsername,
EnvToken,
EnvBaseURL,
EnvNameserver).
WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
expectedMode string
}{
{
desc: "success cpanel mode (default)",
envVars: map[string]string{
EnvUsername: "user",
EnvToken: "secret",
EnvBaseURL: "https://example.com",
EnvNameserver: "ns.example.com:53",
},
expectedMode: "cpanel",
},
{
desc: "success whm mode",
envVars: map[string]string{
EnvMode: "whm",
EnvUsername: "user",
EnvToken: "secret",
EnvBaseURL: "https://example.com",
EnvNameserver: "ns.example.com:53",
},
expectedMode: "whm",
},
{
desc: "missing user",
envVars: map[string]string{
EnvToken: "secret",
EnvBaseURL: "https://example.com",
EnvNameserver: "ns.example.com:53",
},
expected: "cpanel: some credentials information are missing: CPANEL_USERNAME",
},
{
desc: "missing token",
envVars: map[string]string{
EnvUsername: "user",
EnvBaseURL: "https://example.com",
EnvNameserver: "ns.example.com:53",
},
expected: "cpanel: some credentials information are missing: CPANEL_TOKEN",
},
{
desc: "missing base URL",
envVars: map[string]string{
EnvUsername: "user",
EnvToken: "secret",
EnvBaseURL: "",
EnvNameserver: "ns.example.com:53",
},
expected: "cpanel: some credentials information are missing: CPANEL_BASE_URL",
},
{
desc: "missing nameserver",
envVars: map[string]string{
EnvUsername: "user",
EnvToken: "secret",
EnvBaseURL: "https://example.com",
EnvNameserver: "",
},
expected: "cpanel: some credentials information are missing: CPANEL_NAMESERVER",
},
}
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 == "" {
assert.Equal(t, test.expectedMode, p.config.Mode)
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
mode string
username string
token string
baseURL string
nameserver string
expected string
}{
{
desc: "success",
mode: "whm",
username: "user",
token: "secret",
baseURL: "https://example.com",
nameserver: "ns.example.com:53",
},
{
desc: "missing mode",
username: "user",
token: "secret",
baseURL: "https://example.com",
nameserver: "ns.example.com:53",
expected: `cpanel: create client error: unsupported mode: ""`,
},
{
desc: "invalid mode",
mode: "test",
username: "user",
token: "secret",
baseURL: "https://example.com",
nameserver: "ns.example.com:53",
expected: `cpanel: create client error: unsupported mode: "test"`,
},
{
desc: "missing username",
mode: "whm",
username: "",
token: "secret",
baseURL: "https://example.com",
nameserver: "ns.example.com:53",
expected: "cpanel: some credentials information are missing",
},
{
desc: "missing token",
mode: "whm",
username: "user",
token: "",
baseURL: "https://example.com",
nameserver: "ns.example.com:53",
expected: "cpanel: some credentials information are missing",
},
{
desc: "missing base URL",
mode: "whm",
username: "user",
token: "secret",
baseURL: "",
nameserver: "ns.example.com:53",
expected: "cpanel: server information are missing",
},
{
desc: "missing nameserver",
mode: "whm",
username: "user",
token: "secret",
baseURL: "https://example.com",
nameserver: "",
expected: "cpanel: server information are missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.Mode = test.mode
config.Username = test.username
config.Token = test.token
config.BaseURL = test.baseURL
config.Nameserver = test.nameserver
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 Test_getZoneSerial(t *testing.T) {
zones := []shared.ZoneRecord{
{
Type: "comment",
LineIndex: 1,
TextB64: "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t",
},
{
Type: "control",
LineIndex: 2,
TextB64: "JFRUTCAxNDQwMA==",
},
{
DNameB64: "ZXhhbXBsZS5jb20u",
LineIndex: 4,
RecordType: "NS",
Type: "record",
TTL: 86400,
DataB64: []string{"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4="},
},
{
DataB64: []string{
"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4=",
"ZW1haWwuaXB4Y29yZS5jb20u",
"MjAyNDAyMDQwOQ==",
"MzYwMA==",
"MTgwMA==",
"MTIwOTYwMA==",
"ODY0MDA=",
},
RecordType: "SOA",
Type: "record",
TTL: 86400,
LineIndex: 3,
DNameB64: "ZXhhbXBsZS5jb20u",
},
{
RecordType: "A",
Type: "record",
TTL: 3600,
DataB64: []string{"MTAuMTAuMTAuMTA="},
LineIndex: 9,
DNameB64: "ZXhhbXBsZS5jb20u",
},
}
serial, err := getZoneSerial("example.com.", zones)
require.NoError(t, err)
assert.EqualValues(t, 2024020409, serial)
}
func Test_getZoneSerial_error(t *testing.T) {
zones := []shared.ZoneRecord{
{
Type: "comment",
LineIndex: 1,
TextB64: "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t",
},
{
Type: "control",
LineIndex: 2,
TextB64: "JFRUTCAxNDQwMA==",
},
{
DNameB64: "ZXhhbXBsZS5jb20u",
LineIndex: 4,
RecordType: "NS",
Type: "record",
TTL: 86400,
DataB64: []string{"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4="},
},
{
DataB64: []string{
"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4=",
"ZW1haWwuaXB4Y29yZS5jb20u",
"MjAyNDAyMDQwOQ==",
"MzYwMA==",
"MTgwMA==",
"MTIwOTYwMA==",
"ODY0MDA=",
},
RecordType: "SOA",
Type: "record",
TTL: 86400,
LineIndex: 3,
DNameB64: "ZXhhbXBsZS5vcmcu",
},
{
RecordType: "A",
Type: "record",
TTL: 3600,
DataB64: []string{"MTAuMTAuMTAuMTA="},
LineIndex: 9,
DNameB64: "ZXhhbXBsZS5jb20u",
},
}
serial, err := getZoneSerial("example.com.", zones)
require.Error(t, err)
assert.EqualValues(t, 0, serial)
}
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)
time.Sleep(1 * time.Second)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}

View file

@ -0,0 +1,155 @@
package cpanel
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
const statusFailed = 0
type Client struct {
username string
token string
baseURL *url.URL
HTTPClient *http.Client
}
func NewClient(baseURL string, username string, token string) (*Client, error) {
apiEndpoint, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
return &Client{
username: username,
token: token,
baseURL: apiEndpoint.JoinPath("execute"),
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// FetchZoneInformation fetches zone information.
// https://api.docs.cpanel.net/openapi/cpanel/operation/dns-parse_zone/
func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) {
endpoint := c.baseURL.JoinPath("DNS", "parse_zone")
query := endpoint.Query()
query.Set("zone", domain)
endpoint.RawQuery = query.Encode()
var result APIResponse[[]shared.ZoneRecord]
err := c.doRequest(ctx, endpoint, &result)
if err != nil {
return nil, err
}
if result.Status == statusFailed {
return nil, toError(result)
}
return result.Data, nil
}
// AddRecord adds a new record.
//
// add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}'
func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) {
data, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON data: %w", err)
}
return c.updateZone(ctx, serial, domain, "add", string(data))
}
// EditRecord edits an existing record.
//
// edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}'
func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) {
data, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON data: %w", err)
}
return c.updateZone(ctx, serial, domain, "edit", string(data))
}
// DeleteRecord deletes an existing record.
//
// remove=22
func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) {
return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex))
}
// https://api.docs.cpanel.net/openapi/cpanel/operation/dns-mass_edit_zone/
func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) {
endpoint := c.baseURL.JoinPath("DNS", "mass_edit_zone")
query := endpoint.Query()
query.Set("serial", strconv.FormatUint(uint64(serial), 10))
query.Set(action, data)
query.Set("zone", domain)
endpoint.RawQuery = query.Encode()
var result APIResponse[shared.ZoneSerial]
err := c.doRequest(ctx, endpoint, &result)
if err != nil {
return nil, err
}
if result.Status == statusFailed {
return nil, toError(result)
}
return &result.Data, nil
}
func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
if err != nil {
return fmt.Errorf("unable to create request: %w", err)
}
// https://api.docs.cpanel.net/cpanel/tokens/#using-an-api-token
req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", c.username, c.token))
req.Header.Set("Accept", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
}
if result == nil {
return nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errutils.NewReadResponseError(req, resp.StatusCode, err)
}
err = json.Unmarshal(raw, result)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
}
return nil
}

View file

@ -0,0 +1,170 @@
package cpanel
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTest(t *testing.T, pattern string, filename string) *Client {
t.Helper()
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
return
}
open, err := os.Open(filepath.Join("fixtures", filename))
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = open.Close() }()
rw.WriteHeader(http.StatusOK)
_, err = io.Copy(rw, open)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
})
client, err := NewClient(server.URL, "user", "secret")
require.NoError(t, err)
client.HTTPClient = server.Client()
return client
}
func TestClient_FetchZoneInformation(t *testing.T) {
client := setupTest(t, "/execute/DNS/parse_zone", "zone-info.json")
zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
require.NoError(t, err)
expected := []shared.ZoneRecord{{
LineIndex: 22,
Type: "record",
DataB64: []string{"dGV4YXMuY29tLg=="},
DNameB64: "dGV4YXMuY29tLg==",
RecordType: "MX",
TTL: 14400,
}}
assert.Equal(t, expected, zoneInfo)
}
func TestClient_FetchZoneInformation_error(t *testing.T) {
client := setupTest(t, "/execute/DNS/parse_zone", "zone-info_error.json")
zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
require.Error(t, err)
assert.Nil(t, zoneInfo)
}
func TestClient_AddRecord(t *testing.T) {
client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json")
record := shared.Record{
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
assert.Equal(t, expected, zoneSerial)
}
func TestClient_AddRecord_error(t *testing.T) {
client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json")
record := shared.Record{
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
require.Error(t, err)
assert.Nil(t, zoneSerial)
}
func TestClient_EditRecord(t *testing.T) {
client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json")
record := shared.Record{
LineIndex: 9,
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
assert.Equal(t, expected, zoneSerial)
}
func TestClient_EditRecord_error(t *testing.T) {
client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json")
record := shared.Record{
LineIndex: 9,
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
require.Error(t, err)
assert.Nil(t, zoneSerial)
}
func TestClient_DeleteRecord(t *testing.T) {
client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json")
zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
assert.Equal(t, expected, zoneSerial)
}
func TestClient_DeleteRecord_error(t *testing.T) {
client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json")
zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
require.Error(t, err)
assert.Nil(t, zoneSerial)
}

View file

@ -0,0 +1,12 @@
{
"metadata": {
"transformed": 1
},
"messages": null,
"status": 1,
"warnings": null,
"errors": null,
"data": {
"new_serial": "2021031903"
}
}

View file

@ -0,0 +1,14 @@
{
"warnings": null,
"messages": [
"a",
"b",
"c"
],
"data": null,
"errors": [
"You do not control a DNS zone named example.com."
],
"metadata": {},
"status": 0
}

View file

@ -0,0 +1,21 @@
{
"metadata": {
"transformed": 1
},
"messages": null,
"status": 1,
"warnings": null,
"errors": null,
"data": [
{
"line_index": 22,
"dname_b64": "dGV4YXMuY29tLg==",
"data_b64": [
"dGV4YXMuY29tLg=="
],
"type": "record",
"ttl": 14400,
"record_type": "MX"
}
]
}

View file

@ -0,0 +1,14 @@
{
"warnings": null,
"messages": [
"a",
"b",
"c"
],
"data": null,
"errors": [
"You do not control a DNS zone named example.com."
],
"metadata": {},
"status": 0
}

View file

@ -0,0 +1,24 @@
package cpanel
import (
"fmt"
"strings"
)
type APIResponse[T any] struct {
Metadata Metadata `json:"metadata,omitempty"`
Data T `json:"data,omitempty"`
Status int `json:"status,omitempty"`
Messages []string `json:"messages,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type Metadata struct {
Transformed int `json:"transformed,omitempty"`
}
func toError[T any](r APIResponse[T]) error {
return fmt.Errorf("error(%d): %s: %s", r.Status, strings.Join(r.Errors, ", "), strings.Join(r.Messages, ", "))
}

View file

@ -0,0 +1,67 @@
package shared
import (
"fmt"
"os"
"strconv"
"time"
"github.com/miekg/dns"
)
type DNSClient struct {
timeout time.Duration
}
func NewDNSClient(timeout time.Duration) *DNSClient {
return &DNSClient{timeout: timeout}
}
func (d DNSClient) SOACall(fqdn, nameserver string) (*dns.SOA, error) {
m := new(dns.Msg)
m.SetQuestion(fqdn, dns.TypeSOA)
m.SetEdns0(4096, false)
in, err := d.sendDNSQuery(m, nameserver)
if err != nil {
return nil, err
}
if len(in.Answer) == 0 {
if len(in.Ns) > 0 {
if soa, ok := in.Ns[0].(*dns.SOA); ok && fqdn != soa.Hdr.Name {
return d.SOACall(soa.Hdr.Name, nameserver)
}
}
return nil, fmt.Errorf("empty answer for %s in %s", fqdn, nameserver)
}
for _, rr := range in.Answer {
if soa, ok := rr.(*dns.SOA); ok {
return soa, nil
}
}
return nil, fmt.Errorf("SOA not found for %s in %s", fqdn, nameserver)
}
func (d DNSClient) sendDNSQuery(m *dns.Msg, ns string) (*dns.Msg, error) {
if ok, _ := strconv.ParseBool(os.Getenv("LEGO_EXPERIMENTAL_DNS_TCP_ONLY")); ok {
tcp := &dns.Client{Net: "tcp", Timeout: d.timeout}
in, _, err := tcp.Exchange(m, ns)
return in, err
}
udp := &dns.Client{Net: "udp", Timeout: d.timeout}
in, _, err := udp.Exchange(m, ns)
if in != nil && in.Truncated {
tcp := &dns.Client{Net: "tcp", Timeout: d.timeout}
// If the TCP request succeeds, the err will reset to nil
in, _, err = tcp.Exchange(m, ns)
}
return in, err
}

View file

@ -0,0 +1,23 @@
package shared
type Record struct {
DName string `json:"dname,omitempty"`
TTL int `json:"ttl,omitempty"`
RecordType string `json:"record_type,omitempty"`
Data []string `json:"data,omitempty"`
LineIndex int `json:"line_index,omitempty"`
}
type ZoneRecord struct {
LineIndex int `json:"line_index,omitempty"`
Type string `json:"type,omitempty"`
DataB64 []string `json:"data_b64,omitempty"`
DNameB64 string `json:"dname_b64,omitempty"`
TextB64 string `json:"text_b64,omitempty"`
RecordType string `json:"record_type,omitempty"`
TTL int `json:"ttl,omitempty"`
}
type ZoneSerial struct {
NewSerial string `json:"new_serial,omitempty"`
}

View file

@ -0,0 +1,159 @@
package whm
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
const statusFailed = 0
type Client struct {
username string
token string
baseURL *url.URL
HTTPClient *http.Client
}
func NewClient(baseURL string, username string, token string) (*Client, error) {
apiEndpoint, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
return &Client{
username: username,
token: token,
baseURL: apiEndpoint.JoinPath("json-api"),
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// FetchZoneInformation fetches zone information.
// https://api.docs.cpanel.net/openapi/whm/operation/parse_dns_zone/
func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) {
endpoint := c.baseURL.JoinPath("parse_dns_zone")
query := endpoint.Query()
query.Set("zone", domain)
endpoint.RawQuery = query.Encode()
var result APIResponse[ZoneData]
err := c.doRequest(ctx, endpoint, &result)
if err != nil {
return nil, err
}
if result.Metadata.Result == statusFailed {
return nil, toError(result.Metadata)
}
return result.Data.Payload, nil
}
// AddRecord adds a new record.
//
// add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}'
func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) {
data, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON data: %w", err)
}
return c.updateZone(ctx, serial, domain, "add", string(data))
}
// EditRecord edits an existing record.
//
// edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}'
func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) {
data, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON data: %w", err)
}
return c.updateZone(ctx, serial, domain, "edit", string(data))
}
// DeleteRecord deletes an existing record.
//
// remove=22
func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) {
return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex))
}
// https://api.docs.cpanel.net/openapi/whm/operation/mass_edit_dns_zone/
func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) {
endpoint := c.baseURL.JoinPath("mass_edit_dns_zone")
query := endpoint.Query()
query.Set("serial", strconv.FormatUint(uint64(serial), 10))
query.Set(action, data)
query.Set("zone", domain)
endpoint.RawQuery = query.Encode()
var result APIResponse[shared.ZoneSerial]
err := c.doRequest(ctx, endpoint, &result)
if err != nil {
return nil, err
}
if result.Metadata.Result == statusFailed {
return nil, toError(result.Metadata)
}
return &result.Data, nil
}
func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error {
query := endpoint.Query()
query.Set("api.version", "1")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
if err != nil {
return fmt.Errorf("unable to create request: %w", err)
}
// https://api.docs.cpanel.net/whm/tokens/
req.Header.Set("Authorization", fmt.Sprintf("whm %s:%s", c.username, c.token))
req.Header.Set("Accept", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
}
if result == nil {
return nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errutils.NewReadResponseError(req, resp.StatusCode, err)
}
err = json.Unmarshal(raw, result)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
}
return nil
}

View file

@ -0,0 +1,170 @@
package whm
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTest(t *testing.T, pattern string, filename string) *Client {
t.Helper()
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
return
}
open, err := os.Open(filepath.Join("fixtures", filename))
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = open.Close() }()
rw.WriteHeader(http.StatusOK)
_, err = io.Copy(rw, open)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
})
client, err := NewClient(server.URL, "user", "secret")
require.NoError(t, err)
client.HTTPClient = server.Client()
return client
}
func TestClient_FetchZoneInformation(t *testing.T) {
client := setupTest(t, "/json-api/parse_dns_zone", "zone-info.json")
zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
require.NoError(t, err)
expected := []shared.ZoneRecord{{
LineIndex: 22,
Type: "record",
DataB64: []string{"dGV4YXMuY29tLg=="},
DNameB64: "dGV4YXMuY29tLg==",
RecordType: "MX",
TTL: 14400,
}}
assert.Equal(t, expected, zoneInfo)
}
func TestClient_FetchZoneInformation_error(t *testing.T) {
client := setupTest(t, "/json-api/parse_dns_zone", "zone-info_error.json")
zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
require.Error(t, err)
assert.Nil(t, zoneInfo)
}
func TestClient_AddRecord(t *testing.T) {
client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json")
record := shared.Record{
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
assert.Equal(t, expected, zoneSerial)
}
func TestClient_AddRecord_error(t *testing.T) {
client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json")
record := shared.Record{
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
require.Error(t, err)
assert.Nil(t, zoneSerial)
}
func TestClient_EditRecord(t *testing.T) {
client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json")
record := shared.Record{
LineIndex: 9,
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
assert.Equal(t, expected, zoneSerial)
}
func TestClient_EditRecord_error(t *testing.T) {
client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json")
record := shared.Record{
LineIndex: 9,
DName: "example",
TTL: 14400,
RecordType: "TXT",
Data: []string{"string1", "string2"},
}
zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
require.Error(t, err)
assert.Nil(t, zoneSerial)
}
func TestClient_DeleteRecord(t *testing.T) {
client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json")
zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
assert.Equal(t, expected, zoneSerial)
}
func TestClient_DeleteRecord_error(t *testing.T) {
client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json")
zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
require.Error(t, err)
assert.Nil(t, zoneSerial)
}

View file

@ -0,0 +1,11 @@
{
"data": {
"new_serial": "2021031903"
},
"metadata": {
"command": "mass_edit_dns_zone",
"reason": "OK",
"result": 1,
"version": 1
}
}

View file

@ -0,0 +1,9 @@
{
"data": null,
"metadata": {
"command": "mass_edit_dns_zone",
"reason": "There is a problem",
"result": 0,
"version": 1
}
}

View file

@ -0,0 +1,22 @@
{
"data": {
"payload": [
{
"line_index": 22,
"type": "record",
"data_b64": [
"dGV4YXMuY29tLg=="
],
"dname_b64": "dGV4YXMuY29tLg==",
"record_type": "MX",
"ttl": 14400
}
]
},
"metadata": {
"command": "parse_dns_zone",
"reason": "OK",
"result": 1,
"version": 1
}
}

View file

@ -0,0 +1,9 @@
{
"data": null,
"metadata": {
"command": "parse_dns_zone",
"reason": "There is a problem",
"result": 0,
"version": 1
}
}

View file

@ -0,0 +1,27 @@
package whm
import (
"fmt"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
)
type APIResponse[T any] struct {
Metadata Metadata `json:"metadata,omitempty"`
Data T `json:"data,omitempty"`
}
type Metadata struct {
Command string `json:"command,omitempty"`
Reason string `json:"reason,omitempty"`
Result int `json:"result,omitempty"`
Version int `json:"version,omitempty"`
}
type ZoneData struct {
Payload []shared.ZoneRecord `json:"payload,omitempty"`
}
func toError(m Metadata) error {
return fmt.Errorf("%s error(%d): %s", m.Command, m.Result, m.Reason)
}

View file

@ -26,6 +26,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/cloudxns"
"github.com/go-acme/lego/v4/providers/dns/conoha"
"github.com/go-acme/lego/v4/providers/dns/constellix"
"github.com/go-acme/lego/v4/providers/dns/cpanel"
"github.com/go-acme/lego/v4/providers/dns/derak"
"github.com/go-acme/lego/v4/providers/dns/desec"
"github.com/go-acme/lego/v4/providers/dns/designate"
@ -177,6 +178,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return conoha.NewDNSProvider()
case "constellix":
return constellix.NewDNSProvider()
case "cpanel":
return cpanel.NewDNSProvider()
case "derak":
return derak.NewDNSProvider()
case "desec":