Skip to content

Commit b8beddc

Browse files
authored
Add DNS provider for ZoneEdit (#2578)
1 parent 52e167c commit b8beddc

File tree

13 files changed

+575
-3
lines changed

13 files changed

+575
-3
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,10 +248,10 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
248248
<td><a href="https://go-acme.github.io/lego/dns/yandex/">Yandex PDD</a></td>
249249
<td><a href="https://go-acme.github.io/lego/dns/zoneee/">Zone.ee</a></td>
250250
</tr><tr>
251+
<td><a href="https://go-acme.github.io/lego/dns/zoneedit/">ZoneEdit</a></td>
251252
<td><a href="https://go-acme.github.io/lego/dns/zonomi/">Zonomi</a></td>
252253
<td></td>
253254
<td></td>
254-
<td></td>
255255
</tr></table>
256256

257257
<!-- END DNS PROVIDERS LIST -->

cmd/zz_gen_cmd_dnshelp.go

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/content/dns/zz_gen_zoneedit.md

Lines changed: 68 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/data/zz_cli_help.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ USAGE:
9494
9595
OPTIONS:
9696
--days value The number of days left on a certificate to renew it. (default: 30)
97-
--dynamic Dynamically defines the renewal date. (1/3rd of the lifetime left or 1/2 of the lifetime left, if the lifetime is shorter than 10 days) (default: false)
97+
--dynamic Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5. (default: false)
9898
--ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. (default: false)
9999
--ari-wait-to-renew-duration value The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s)
100100
--reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false)
@@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run:
152152
$ lego dnshelp -c code
153153
154154
Supported DNS providers:
155-
acme-dns, active24, alidns, allinkl, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneee, zonomi
155+
acme-dns, active24, alidns, allinkl, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
156156
157157
More information: https://go-acme.github.io/lego/dns
158158
"""
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package internal
2+
3+
import (
4+
"bytes"
5+
"encoding/xml"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"slices"
12+
"time"
13+
14+
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
15+
)
16+
17+
const defaultBaseURL = "https://dynamic.zoneedit.com"
18+
19+
// Client the ZoneEdit API client.
20+
type Client struct {
21+
user string
22+
authToken string
23+
24+
baseURL *url.URL
25+
HTTPClient *http.Client
26+
}
27+
28+
// NewClient creates a new Client.
29+
func NewClient(user, authToken string) (*Client, error) {
30+
if user == "" || authToken == "" {
31+
return nil, errors.New("credentials missing")
32+
}
33+
34+
baseURL, _ := url.Parse(defaultBaseURL)
35+
36+
return &Client{
37+
user: user,
38+
authToken: authToken,
39+
baseURL: baseURL,
40+
HTTPClient: &http.Client{Timeout: 10 * time.Second},
41+
}, nil
42+
}
43+
44+
func (c *Client) CreateTXTRecord(domain, rdata string) error {
45+
return c.perform("txt-create.php", domain, rdata)
46+
}
47+
48+
func (c *Client) DeleteTXTRecord(domain, rdata string) error {
49+
return c.perform("txt-delete.php", domain, rdata)
50+
}
51+
52+
func (c *Client) perform(actionPath, domain, rdata string) error {
53+
endpoint := c.baseURL.JoinPath(actionPath)
54+
55+
query := endpoint.Query()
56+
query.Set("host", domain)
57+
query.Set("rdata", rdata)
58+
endpoint.RawQuery = query.Encode()
59+
60+
req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody)
61+
if err != nil {
62+
return err
63+
}
64+
65+
return c.do(req)
66+
}
67+
68+
func (c *Client) do(req *http.Request) error {
69+
req.SetBasicAuth(c.user, c.authToken)
70+
71+
resp, err := c.HTTPClient.Do(req)
72+
if err != nil {
73+
return errutils.NewHTTPDoError(req, err)
74+
}
75+
76+
defer func() { _ = resp.Body.Close() }()
77+
78+
if resp.StatusCode/100 != 2 {
79+
raw, _ := io.ReadAll(resp.Body)
80+
81+
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
82+
}
83+
84+
raw, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
return errutils.NewReadResponseError(req, resp.StatusCode, err)
87+
}
88+
89+
if bytes.Contains(raw, []byte("SUCCESS CODE")) {
90+
return nil
91+
}
92+
93+
raw = bytes.TrimSpace(raw)
94+
95+
// The answer is not an XML valid (missing closing), so I fix it to parse it.
96+
if bytes.HasSuffix(raw, []byte(">")) {
97+
raw = slices.Concat(raw[:len(raw)-1], []byte("/>"))
98+
}
99+
100+
var apiErr APIError
101+
err = xml.Unmarshal(raw, &apiErr)
102+
if err != nil {
103+
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
104+
}
105+
106+
return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr)
107+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package internal
2+
3+
import (
4+
"net/http/httptest"
5+
"net/url"
6+
"testing"
7+
8+
"github.com/go-acme/lego/v4/platform/tester/servermock"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func mockBuilder() *servermock.Builder[*Client] {
13+
return servermock.NewBuilder(func(server *httptest.Server) (*Client, error) {
14+
client, err := NewClient("user", "secret")
15+
if err != nil {
16+
return nil, err
17+
}
18+
19+
client.baseURL, _ = url.Parse(server.URL)
20+
client.HTTPClient = server.Client()
21+
22+
return client, nil
23+
})
24+
}
25+
26+
func TestClient_CreateTXTRecord(t *testing.T) {
27+
client := mockBuilder().
28+
Route("GET /txt-create.php",
29+
servermock.ResponseFromFixture("success.xml")).
30+
Build(t)
31+
32+
err := client.CreateTXTRecord("_acme-challenge.example.com", "value")
33+
require.NoError(t, err)
34+
}
35+
36+
func TestClient_CreateTXTRecord_error(t *testing.T) {
37+
client := mockBuilder().
38+
Route("GET /txt-create.php",
39+
servermock.ResponseFromFixture("error.xml")).
40+
Build(t)
41+
42+
err := client.CreateTXTRecord("_acme-challenge.example.com", "value")
43+
require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)")
44+
}
45+
46+
func TestClient_DeleteTXTRecord(t *testing.T) {
47+
client := mockBuilder().
48+
Route("GET /txt-delete.php",
49+
servermock.ResponseFromFixture("success.xml")).
50+
Build(t)
51+
52+
err := client.DeleteTXTRecord("_acme-challenge.example.com", "value")
53+
require.NoError(t, err)
54+
}
55+
56+
func TestClient_DeleteTXTRecord_error(t *testing.T) {
57+
client := mockBuilder().
58+
Route("GET /txt-delete.php",
59+
servermock.ResponseFromFixture("error.xml")).
60+
Build(t)
61+
62+
err := client.DeleteTXTRecord("_acme-challenge.example.com", "value")
63+
require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)")
64+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<ERROR CODE="708" TEXT="Failed Login: user" ZONE="_acme-challenge.example.com">
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<SUCCESS CODE="200" TEXT="_acme-challenge.example.ca TXT with rdata yaZy0O9QYEKtqBWeJqq7vJYjFuUoB0c0dzjo7UaJcMs deleted" ZONE="example.com">
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package internal
2+
3+
import (
4+
"encoding/xml"
5+
"fmt"
6+
)
7+
8+
type APIError struct {
9+
XMLName xml.Name `xml:"ERROR"`
10+
Text string `xml:",chardata"`
11+
Code string `xml:"CODE,attr"`
12+
Message string `xml:"TEXT,attr"`
13+
Zone string `xml:"ZONE,attr"`
14+
}
15+
16+
func (a APIError) Error() string {
17+
return fmt.Sprintf("%s: %s (%s)", a.Code, a.Message, a.Zone)
18+
}

0 commit comments

Comments
 (0)