Repository: go-acme/lego (language: go)

Issue:
CPanel Support
Hi there,

Wonder if it would be possible to add support for CPanel for DNS?
I think it would open up abase of users, those that have a domain on an external host and subdomain to other things.
I have a domain I use on a host with CPanel, but I spur off a few subdomains for testing sites etc.
Myself I use exec to run a modified version the script below.

A certbot .py exists for it and I checked over the request params it uses with a CPanel I have on hostgator and it all lines up.
https://github.com/letsdebug/certbot-cpanel-dns-auth-hook 

I dont know go else i'd have a shot at it


Discussion / hints:
Oh one thing I do that nots in that script, is do a delete on the TXT record just incase it still exists.
@ldez, I have a very, very basic working script in bash that uses nslookup to get the current DNS serial, since the CPanel API requires it but does not provide it in any of their calls.

I don't know Go for me to contribute directly.
Do you have some API documentation?

FYI, my nickname is **L**dez
> Do you have some API documentation?

- Fetching current information about the DNS zone: https://api.docs.cpanel.net/openapi/cpanel/operation/dns-parse_zone/
- Updating the zone: https://api.docs.cpanel.net/openapi/cpanel/operation/dns-mass_edit_zone/
- My script: https://pastebin.com/Z83tcY6L

>FYI, my nickname is *L*dez

Sorry, @ldez

edit: I planned to modify the script to fit the "external script" provider call signature, but have instead written it in Rust and build it into a custom traefik image (the last part is in the works still) with the binary included. If that provides any value to you, I can share the repo.

Fix patch:
diff --git a/.golangci.yml b/.golangci.yml
index c00b6c4afa..e079c97616 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -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'
diff --git a/README.md b/README.md
index 213ae89b59..3ded66aeba 100644
--- a/README.md
+++ b/README.md
@@ -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 -->
 
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index f28ef2e69c..39e28944ae 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -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.`)
diff --git a/docs/content/dns/zz_gen_cpanel.md b/docs/content/dns/zz_gen_cpanel.md
new file mode 100644
index 0000000000..ca8cb06b18
--- /dev/null
+++ b/docs/content/dns/zz_gen_cpanel.md
@@ -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. -->
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index bbb0419f03..7254d8c234 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -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
 """
diff --git a/providers/dns/cpanel/cpanel.go b/providers/dns/cpanel/cpanel.go
new file mode 100644
index 0000000000..c5a138950e
--- /dev/null
+++ b/providers/dns/cpanel/cpanel.go
@@ -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
+}
diff --git a/providers/dns/cpanel/cpanel.toml b/providers/dns/cpanel/cpanel.toml
new file mode 100644
index 0000000000..0f328a96d7
--- /dev/null
+++ b/providers/dns/cpanel/cpanel.toml
@@ -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/"
diff --git a/providers/dns/cpanel/internal/cpanel/client.go b/providers/dns/cpanel/internal/cpanel/client.go
new file mode 100644
index 0000000000..3bca6b5217
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/client.go
@@ -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
+}
diff --git a/providers/dns/cpanel/internal/cpanel/fixtures/update-zone.json b/providers/dns/cpanel/internal/cpanel/fixtures/update-zone.json
new file mode 100644
index 0000000000..842d94a571
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/fixtures/update-zone.json
@@ -0,0 +1,12 @@
+{
+  "metadata": {
+    "transformed": 1
+  },
+  "messages": null,
+  "status": 1,
+  "warnings": null,
+  "errors": null,
+  "data": {
+    "new_serial": "2021031903"
+  }
+}
diff --git a/providers/dns/cpanel/internal/cpanel/fixtures/update-zone_error.json b/providers/dns/cpanel/internal/cpanel/fixtures/update-zone_error.json
new file mode 100644
index 0000000000..ed316f1fce
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/fixtures/update-zone_error.json
@@ -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
+}
diff --git a/providers/dns/cpanel/internal/cpanel/fixtures/zone-info.json b/providers/dns/cpanel/internal/cpanel/fixtures/zone-info.json
new file mode 100644
index 0000000000..48833ba314
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/fixtures/zone-info.json
@@ -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"
+    }
+  ]
+}
diff --git a/providers/dns/cpanel/internal/cpanel/fixtures/zone-info_error.json b/providers/dns/cpanel/internal/cpanel/fixtures/zone-info_error.json
new file mode 100644
index 0000000000..ed316f1fce
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/fixtures/zone-info_error.json
@@ -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
+}
diff --git a/providers/dns/cpanel/internal/cpanel/types.go b/providers/dns/cpanel/internal/cpanel/types.go
new file mode 100644
index 0000000000..cb4dbd5356
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/types.go
@@ -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, ", "))
+}
diff --git a/providers/dns/cpanel/internal/shared/dns.go b/providers/dns/cpanel/internal/shared/dns.go
new file mode 100644
index 0000000000..371272713f
--- /dev/null
+++ b/providers/dns/cpanel/internal/shared/dns.go
@@ -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
+}
diff --git a/providers/dns/cpanel/internal/shared/types.go b/providers/dns/cpanel/internal/shared/types.go
new file mode 100644
index 0000000000..a12c554ed5
--- /dev/null
+++ b/providers/dns/cpanel/internal/shared/types.go
@@ -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"`
+}
diff --git a/providers/dns/cpanel/internal/whm/client.go b/providers/dns/cpanel/internal/whm/client.go
new file mode 100644
index 0000000000..d375b83e32
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/client.go
@@ -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
+}
diff --git a/providers/dns/cpanel/internal/whm/fixtures/update-zone.json b/providers/dns/cpanel/internal/whm/fixtures/update-zone.json
new file mode 100644
index 0000000000..fcbd0580d4
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/fixtures/update-zone.json
@@ -0,0 +1,11 @@
+{
+  "data": {
+    "new_serial": "2021031903"
+  },
+  "metadata": {
+    "command": "mass_edit_dns_zone",
+    "reason": "OK",
+    "result": 1,
+    "version": 1
+  }
+}
diff --git a/providers/dns/cpanel/internal/whm/fixtures/update-zone_error.json b/providers/dns/cpanel/internal/whm/fixtures/update-zone_error.json
new file mode 100644
index 0000000000..eff57364c1
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/fixtures/update-zone_error.json
@@ -0,0 +1,9 @@
+{
+  "data": null,
+  "metadata": {
+    "command": "mass_edit_dns_zone",
+    "reason": "There is a problem",
+    "result": 0,
+    "version": 1
+  }
+}
diff --git a/providers/dns/cpanel/internal/whm/fixtures/zone-info.json b/providers/dns/cpanel/internal/whm/fixtures/zone-info.json
new file mode 100644
index 0000000000..bbcfdb3667
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/fixtures/zone-info.json
@@ -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
+  }
+}
diff --git a/providers/dns/cpanel/internal/whm/fixtures/zone-info_error.json b/providers/dns/cpanel/internal/whm/fixtures/zone-info_error.json
new file mode 100644
index 0000000000..336485664e
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/fixtures/zone-info_error.json
@@ -0,0 +1,9 @@
+{
+  "data": null,
+  "metadata": {
+    "command": "parse_dns_zone",
+    "reason": "There is a problem",
+    "result": 0,
+    "version": 1
+  }
+}
diff --git a/providers/dns/cpanel/internal/whm/types.go b/providers/dns/cpanel/internal/whm/types.go
new file mode 100644
index 0000000000..f1884a04d7
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/types.go
@@ -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)
+}
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 03db691646..408a87c46f 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -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":


Test patch:
diff --git a/providers/dns/cpanel/cpanel_test.go b/providers/dns/cpanel/cpanel_test.go
new file mode 100644
index 0000000000..fb39193d70
--- /dev/null
+++ b/providers/dns/cpanel/cpanel_test.go
@@ -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)
+}
diff --git a/providers/dns/cpanel/internal/cpanel/client_test.go b/providers/dns/cpanel/internal/cpanel/client_test.go
new file mode 100644
index 0000000000..8516259d68
--- /dev/null
+++ b/providers/dns/cpanel/internal/cpanel/client_test.go
@@ -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)
+}
diff --git a/providers/dns/cpanel/internal/whm/client_test.go b/providers/dns/cpanel/internal/whm/client_test.go
new file mode 100644
index 0000000000..f4f6d7b191
--- /dev/null
+++ b/providers/dns/cpanel/internal/whm/client_test.go
@@ -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)
+}