forked from TrueCloudLab/lego
Add DNS provider for CPanel and WHM (#1977)
This commit is contained in:
parent
719adc3964
commit
83ff393131
25 changed files with 1778 additions and 28 deletions
|
@ -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'
|
||||
|
|
54
README.md
54
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 -->
|
||||
|
||||
|
|
|
@ -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.`)
|
||||
|
|
86
docs/content/dns/zz_gen_cpanel.md
Normal file
86
docs/content/dns/zz_gen_cpanel.md
Normal 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. -->
|
|
@ -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
|
||||
"""
|
||||
|
|
346
providers/dns/cpanel/cpanel.go
Normal file
346
providers/dns/cpanel/cpanel.go
Normal 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
|
||||
}
|
42
providers/dns/cpanel/cpanel.toml
Normal file
42
providers/dns/cpanel/cpanel.toml
Normal 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/"
|
338
providers/dns/cpanel/cpanel_test.go
Normal file
338
providers/dns/cpanel/cpanel_test.go
Normal 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)
|
||||
}
|
155
providers/dns/cpanel/internal/cpanel/client.go
Normal file
155
providers/dns/cpanel/internal/cpanel/client.go
Normal 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
|
||||
}
|
170
providers/dns/cpanel/internal/cpanel/client_test.go
Normal file
170
providers/dns/cpanel/internal/cpanel/client_test.go
Normal 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)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"metadata": {
|
||||
"transformed": 1
|
||||
},
|
||||
"messages": null,
|
||||
"status": 1,
|
||||
"warnings": null,
|
||||
"errors": null,
|
||||
"data": {
|
||||
"new_serial": "2021031903"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
21
providers/dns/cpanel/internal/cpanel/fixtures/zone-info.json
Normal file
21
providers/dns/cpanel/internal/cpanel/fixtures/zone-info.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
||||
}
|
24
providers/dns/cpanel/internal/cpanel/types.go
Normal file
24
providers/dns/cpanel/internal/cpanel/types.go
Normal 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, ", "))
|
||||
}
|
67
providers/dns/cpanel/internal/shared/dns.go
Normal file
67
providers/dns/cpanel/internal/shared/dns.go
Normal 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
|
||||
}
|
23
providers/dns/cpanel/internal/shared/types.go
Normal file
23
providers/dns/cpanel/internal/shared/types.go
Normal 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"`
|
||||
}
|
159
providers/dns/cpanel/internal/whm/client.go
Normal file
159
providers/dns/cpanel/internal/whm/client.go
Normal 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
|
||||
}
|
170
providers/dns/cpanel/internal/whm/client_test.go
Normal file
170
providers/dns/cpanel/internal/whm/client_test.go
Normal 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)
|
||||
}
|
11
providers/dns/cpanel/internal/whm/fixtures/update-zone.json
Normal file
11
providers/dns/cpanel/internal/whm/fixtures/update-zone.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"data": {
|
||||
"new_serial": "2021031903"
|
||||
},
|
||||
"metadata": {
|
||||
"command": "mass_edit_dns_zone",
|
||||
"reason": "OK",
|
||||
"result": 1,
|
||||
"version": 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"data": null,
|
||||
"metadata": {
|
||||
"command": "mass_edit_dns_zone",
|
||||
"reason": "There is a problem",
|
||||
"result": 0,
|
||||
"version": 1
|
||||
}
|
||||
}
|
22
providers/dns/cpanel/internal/whm/fixtures/zone-info.json
Normal file
22
providers/dns/cpanel/internal/whm/fixtures/zone-info.json
Normal 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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"data": null,
|
||||
"metadata": {
|
||||
"command": "parse_dns_zone",
|
||||
"reason": "There is a problem",
|
||||
"result": 0,
|
||||
"version": 1
|
||||
}
|
||||
}
|
27
providers/dns/cpanel/internal/whm/types.go
Normal file
27
providers/dns/cpanel/internal/whm/types.go
Normal 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)
|
||||
}
|
|
@ -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":
|
||||
|
|
Loading…
Reference in a new issue