Repository: go-acme/lego (go)

Issue:
Support for provider: Zoneedit
### Welcome

- [x] Yes, I've searched similar issues on GitHub and didn't find any.
- [x] Yes, the DNS provider exposes a public API.
- [x] Yes, I know that the lego maintainers don't have an account in all DNS providers in the world.
- [ ] Yes, I'm able to create a pull request and be able to maintain the implementation.
- [x] Yes, I'm able to test an implementation if someone creates a pull request to add the support of this DNS provider.

### How do you use lego?

Through Traefik

### Link to the DNS provider

https://zoneedit.com

### Link to the API documentation

https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns

### Additional Notes

Is there any chance of adding ZoneEdit as a supported provider for certificate retrievals?
They have a setup similar to EASYDNS, there's a DYN endpoint and a token available. it can write the TXT to Cname and delete it as well.

Pull request description:
Add DNS provider for ZoneEdit
- [x] adds a description to your PR
- [x] have a homogeneous design with the other providers
- [x] add tests (units)
- [x] add tests ("live")
- [x] add a provider descriptor
- [x] generate CLI help, documentation, and readme.
- [x] be able to do: _(and put the output of this command to a comment)_
  ```bash
  make build
  rm -rf .lego

  ZONEEDIT_USER="xxx" \
  ZONEEDIT_AUTH_TOKEN="yyy" \
  ./dist/lego --email you@example.com --dns zoneedit -d '*.example.com' -d example.com -s https://acme-staging-v02.api.letsencrypt.org/directory  run
  ```
  Note that the wildcard domain is important.
- [x] pass the linter
- [x] do `go mod tidy`

Ping @mikaelheidrich, can you run the command (with your domain, email, credentials, etc.)?

Closes #2577

<details><summary>How to test this PR?</summary>

1. You need [Go](https://go.dev/doc/install)
2. Check out the PR:
    ```bash
    git clone https://github.com/ldez/lego.git
    cd lego
    git checkout feat/zoneedit
    ```
3. Compile lego:
    - if you have `make`: `make build`
    - if you don't make `make`: `go build -o dist/lego ./cmd/lego`
4. Run the following command with your information (email, domain, credentials):
    ```bash
    ZONEEDIT_USER_ID="xxx" \
    ZONEEDIT_PASSWORD="yyy" \
    ./dist/lego --email you@example.com --dns zoneedit -d '*.example.com' -d example.com -s https://acme-staging-v02.api.letsencrypt.org/directory run
    ```
   **The wildcard domain is important**
5. Before each run of the command, you should clean your local environment:
    ```bash
    rm -rf .lego
    ```

</details>


Patch:
diff --git a/README.md b/README.md
index f527a082..59b62a8d 100644
--- a/README.md
+++ b/README.md
@@ -248,10 +248,10 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
   <td><a href="https://go-acme.github.io/lego/dns/yandex/">Yandex PDD</a></td>
   <td><a href="https://go-acme.github.io/lego/dns/zoneee/">Zone.ee</a></td>
 </tr><tr>
+  <td><a href="https://go-acme.github.io/lego/dns/zoneedit/">ZoneEdit</a></td>
   <td><a href="https://go-acme.github.io/lego/dns/zonomi/">Zonomi</a></td>
   <td></td>
   <td></td>
-  <td></td>
 </tr></table>
 
 <!-- END DNS PROVIDERS LIST -->
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 8821d0d1..fe3d8813 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -167,6 +167,7 @@ func allDNSCodes() string {
 		"yandex",
 		"yandex360",
 		"yandexcloud",
+		"zoneedit",
 		"zoneee",
 		"zonomi",
 	}
@@ -3488,6 +3489,26 @@ func displayDNSHelp(w io.Writer, name string) error {
 		ew.writeln()
 		ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandexcloud`)
 
+	case "zoneedit":
+		// generated from: providers/dns/zoneedit/zoneedit.toml
+		ew.writeln(`Configuration for ZoneEdit.`)
+		ew.writeln(`Code:	'zoneedit'`)
+		ew.writeln(`Since:	'v4.25.0'`)
+		ew.writeln()
+
+		ew.writeln(`Credentials:`)
+		ew.writeln(`	- "ZONEEDIT_PASSWORD":	Password`)
+		ew.writeln(`	- "ZONEEDIT_USER_ID":	User ID`)
+		ew.writeln()
+
+		ew.writeln(`Additional Configuration:`)
+		ew.writeln(`	- "ZONEEDIT_HTTP_TIMEOUT":	API request timeout in seconds (Default: 30)`)
+		ew.writeln(`	- "ZONEEDIT_POLLING_INTERVAL":	Time between DNS propagation check in seconds (Default: 2)`)
+		ew.writeln(`	- "ZONEEDIT_PROPAGATION_TIMEOUT":	Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+
+		ew.writeln()
+		ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneedit`)
+
 	case "zoneee":
 		// generated from: providers/dns/zoneee/zoneee.toml
 		ew.writeln(`Configuration for Zone.ee.`)
diff --git a/docs/content/dns/zz_gen_zoneedit.md b/docs/content/dns/zz_gen_zoneedit.md
new file mode 100644
index 00000000..f39f1068
--- /dev/null
+++ b/docs/content/dns/zz_gen_zoneedit.md
@@ -0,0 +1,68 @@
+---
+title: "ZoneEdit"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: zoneedit
+dnsprovider:
+  since:    "v4.25.0"
+  code:     "zoneedit"
+  url:      "https://www.zoneedit.com"
+---
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/zoneedit/zoneedit.toml -->
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+
+
+Configuration for [ZoneEdit](https://www.zoneedit.com).
+
+
+<!--more-->
+
+- Code: `zoneedit`
+- Since: v4.25.0
+
+
+Here is an example bash command using the ZoneEdit provider:
+
+```bash
+ZONEEDIT_USER_ID="xxxxxxxxxxxxxxxxxxxxx" \
+ZONEEDIT_PASSWORD="xxxxxxxxxxxxxxxxxxxxx" \
+lego --email you@example.com --dns zoneedit -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ZONEEDIT_PASSWORD` | Password |
+| `ZONEEDIT_USER_ID` | User ID |
+
+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 |
+|--------------------------------|-------------|
+| `ZONEEDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ZONEEDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ZONEEDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns)
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/zoneedit/zoneedit.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 723f063c..0ba9b9d8 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -94,7 +94,7 @@ USAGE:
 
 OPTIONS:
    --days value                              The number of days left on a certificate to renew it. (default: 30)
-   --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)
+   --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)
    --ari-disable                             Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. (default: false)
    --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)
    --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:
   $ lego dnshelp -c code
 
 Supported DNS providers:
-  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
+  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
 
 More information: https://go-acme.github.io/lego/dns
 """
diff --git a/providers/dns/zoneedit/internal/client.go b/providers/dns/zoneedit/internal/client.go
new file mode 100644
index 00000000..e97f4beb
--- /dev/null
+++ b/providers/dns/zoneedit/internal/client.go
@@ -0,0 +1,107 @@
+package internal
+
+import (
+	"bytes"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"slices"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://dynamic.zoneedit.com"
+
+// Client the ZoneEdit API client.
+type Client struct {
+	user      string
+	authToken string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(user, authToken string) (*Client, error) {
+	if user == "" || authToken == "" {
+		return nil, errors.New("credentials missing")
+	}
+
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		user:       user,
+		authToken:  authToken,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
+	}, nil
+}
+
+func (c *Client) CreateTXTRecord(domain, rdata string) error {
+	return c.perform("txt-create.php", domain, rdata)
+}
+
+func (c *Client) DeleteTXTRecord(domain, rdata string) error {
+	return c.perform("txt-delete.php", domain, rdata)
+}
+
+func (c *Client) perform(actionPath, domain, rdata string) error {
+	endpoint := c.baseURL.JoinPath(actionPath)
+
+	query := endpoint.Query()
+	query.Set("host", domain)
+	query.Set("rdata", rdata)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req)
+}
+
+func (c *Client) do(req *http.Request) error {
+	req.SetBasicAuth(c.user, c.authToken)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		raw, _ := io.ReadAll(resp.Body)
+
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if bytes.Contains(raw, []byte("SUCCESS CODE")) {
+		return nil
+	}
+
+	raw = bytes.TrimSpace(raw)
+
+	// The answer is not an XML valid (missing closing), so I fix it to parse it.
+	if bytes.HasSuffix(raw, []byte(">")) {
+		raw = slices.Concat(raw[:len(raw)-1], []byte("/>"))
+	}
+
+	var apiErr APIError
+	err = xml.Unmarshal(raw, &apiErr)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr)
+}
diff --git a/providers/dns/zoneedit/internal/fixtures/error.xml b/providers/dns/zoneedit/internal/fixtures/error.xml
new file mode 100644
index 00000000..6c0f1de6
--- /dev/null
+++ b/providers/dns/zoneedit/internal/fixtures/error.xml
@@ -0,0 +1,1 @@
+<ERROR CODE="708" TEXT="Failed Login: user" ZONE="_acme-challenge.example.com">
diff --git a/providers/dns/zoneedit/internal/fixtures/success.xml b/providers/dns/zoneedit/internal/fixtures/success.xml
new file mode 100644
index 00000000..80d75169
--- /dev/null
+++ b/providers/dns/zoneedit/internal/fixtures/success.xml
@@ -0,0 +1,1 @@
+<SUCCESS CODE="200" TEXT="_acme-challenge.example.ca TXT with rdata yaZy0O9QYEKtqBWeJqq7vJYjFuUoB0c0dzjo7UaJcMs deleted" ZONE="example.com">
diff --git a/providers/dns/zoneedit/internal/types.go b/providers/dns/zoneedit/internal/types.go
new file mode 100644
index 00000000..96fa41c3
--- /dev/null
+++ b/providers/dns/zoneedit/internal/types.go
@@ -0,0 +1,18 @@
+package internal
+
+import (
+	"encoding/xml"
+	"fmt"
+)
+
+type APIError struct {
+	XMLName xml.Name `xml:"ERROR"`
+	Text    string   `xml:",chardata"`
+	Code    string   `xml:"CODE,attr"`
+	Message string   `xml:"TEXT,attr"`
+	Zone    string   `xml:"ZONE,attr"`
+}
+
+func (a APIError) Error() string {
+	return fmt.Sprintf("%s: %s (%s)", a.Code, a.Message, a.Zone)
+}
diff --git a/providers/dns/zoneedit/zoneedit.go b/providers/dns/zoneedit/zoneedit.go
new file mode 100644
index 00000000..875b8423
--- /dev/null
+++ b/providers/dns/zoneedit/zoneedit.go
@@ -0,0 +1,123 @@
+// Package zoneedit implements a DNS provider for solving the DNS-01 challenge using ZoneEdit.
+package zoneedit
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"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/zoneedit/internal"
+)
+
+// Environment variables names.
+const (
+	envNamespace = "ZONEEDIT_"
+
+	EnvUser     = envNamespace + "USER"
+	EnAuthToken = envNamespace + "AUTH_TOKEN"
+
+	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
+	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+	User      string
+	AuthToken string
+
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+	return &Config{
+		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 *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ZoneEdit.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get(EnvUser, EnAuthToken)
+	if err != nil {
+		return nil, fmt.Errorf("zoneedit: %w", err)
+	}
+
+	config := NewDefaultConfig()
+	config.User = values[EnvUser]
+	config.AuthToken = values[EnAuthToken]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ZoneEdit.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("zoneedit: the configuration of the DNS provider is nil")
+	}
+
+	client, err := internal.NewClient(config.User, config.AuthToken)
+	if err != nil {
+		return nil, fmt.Errorf("zoneedit: %w", err)
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{
+		config: config,
+		client: client,
+	}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	info := dns01.GetChallengeInfo(domain, keyAuth)
+
+	err := d.client.CreateTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+	if err != nil {
+		return fmt.Errorf("zoneedit: create TXT record: %w", err)
+	}
+
+	// ERROR CODE="702" TEXT="Minimum 10 seconds between requests"
+	time.Sleep(11 * time.Second)
+
+	return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	info := dns01.GetChallengeInfo(domain, keyAuth)
+
+	err := d.client.DeleteTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+	if err != nil {
+		return fmt.Errorf("zoneedit: delete TXT record: %w", err)
+	}
+
+	// ERROR CODE="702" TEXT="Minimum 10 seconds between requests"
+	time.Sleep(11 * time.Second)
+
+	return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
+}
diff --git a/providers/dns/zoneedit/zoneedit.toml b/providers/dns/zoneedit/zoneedit.toml
new file mode 100644
index 00000000..d3c547c2
--- /dev/null
+++ b/providers/dns/zoneedit/zoneedit.toml
@@ -0,0 +1,23 @@
+Name = "ZoneEdit"
+Description = ''''''
+URL = "https://www.zoneedit.com"
+Code = "zoneedit"
+Since = "v4.25.0"
+
+Example = '''
+ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \
+ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --email you@example.com --dns zoneedit -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+  [Configuration.Credentials]
+    ZONEEDIT_USER = "User ID"
+    ZONEEDIT_AUTH_TOKEN = "Authentication token"
+  [Configuration.Additional]
+    ZONEEDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+    ZONEEDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+    ZONEEDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+  API = "https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns"
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index 5eee04fa..59205f7f 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -161,6 +161,7 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/yandex"
 	"github.com/go-acme/lego/v4/providers/dns/yandex360"
 	"github.com/go-acme/lego/v4/providers/dns/yandexcloud"
+	"github.com/go-acme/lego/v4/providers/dns/zoneedit"
 	"github.com/go-acme/lego/v4/providers/dns/zoneee"
 	"github.com/go-acme/lego/v4/providers/dns/zonomi"
 )
@@ -478,6 +479,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
 		return yandex360.NewDNSProvider()
 	case "yandexcloud":
 		return yandexcloud.NewDNSProvider()
+	case "zoneedit":
+		return zoneedit.NewDNSProvider()
 	case "zoneee":
 		return zoneee.NewDNSProvider()
 	case "zonomi":


Test patch:
diff --git a/providers/dns/zoneedit/internal/client_test.go b/providers/dns/zoneedit/internal/client_test.go
new file mode 100644
index 00000000..1d9f9be7
--- /dev/null
+++ b/providers/dns/zoneedit/internal/client_test.go
@@ -0,0 +1,64 @@
+package internal
+
+import (
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/go-acme/lego/v4/platform/tester/servermock"
+	"github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+	return servermock.NewBuilder(func(server *httptest.Server) (*Client, error) {
+		client, err := NewClient("user", "secret")
+		if err != nil {
+			return nil, err
+		}
+
+		client.baseURL, _ = url.Parse(server.URL)
+		client.HTTPClient = server.Client()
+
+		return client, nil
+	})
+}
+
+func TestClient_CreateTXTRecord(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /txt-create.php",
+			servermock.ResponseFromFixture("success.xml")).
+		Build(t)
+
+	err := client.CreateTXTRecord("_acme-challenge.example.com", "value")
+	require.NoError(t, err)
+}
+
+func TestClient_CreateTXTRecord_error(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /txt-create.php",
+			servermock.ResponseFromFixture("error.xml")).
+		Build(t)
+
+	err := client.CreateTXTRecord("_acme-challenge.example.com", "value")
+	require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)")
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /txt-delete.php",
+			servermock.ResponseFromFixture("success.xml")).
+		Build(t)
+
+	err := client.DeleteTXTRecord("_acme-challenge.example.com", "value")
+	require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord_error(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /txt-delete.php",
+			servermock.ResponseFromFixture("error.xml")).
+		Build(t)
+
+	err := client.DeleteTXTRecord("_acme-challenge.example.com", "value")
+	require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)")
+}
diff --git a/providers/dns/zoneedit/zoneedit_test.go b/providers/dns/zoneedit/zoneedit_test.go
new file mode 100644
index 00000000..2a9b1754
--- /dev/null
+++ b/providers/dns/zoneedit/zoneedit_test.go
@@ -0,0 +1,143 @@
+package zoneedit
+
+import (
+	"testing"
+
+	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUser, EnAuthToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				EnvUser:     "user",
+				EnAuthToken: "secret",
+			},
+		},
+		{
+			desc: "missing user ID",
+			envVars: map[string]string{
+				EnvUser:     "",
+				EnAuthToken: "secret",
+			},
+			expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER",
+		},
+		{
+			desc: "missing auth token",
+			envVars: map[string]string{
+				EnvUser:     "user",
+				EnAuthToken: "",
+			},
+			expected: "zoneedit: some credentials information are missing: ZONEEDIT_AUTH_TOKEN",
+		},
+		{
+			desc:     "missing credentials",
+			envVars:  map[string]string{},
+			expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER,ZONEEDIT_AUTH_TOKEN",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			defer envTest.RestoreEnv()
+			envTest.ClearEnv()
+
+			envTest.Apply(test.envVars)
+
+			p, err := NewDNSProvider()
+
+			if test.expected == "" {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				require.NotNil(t, p.config)
+				require.NotNil(t, p.client)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc      string
+		user      string
+		authToken string
+		expected  string
+	}{
+		{
+			desc:      "success",
+			user:      "user",
+			authToken: "secret",
+		},
+		{
+			desc:      "missing user ID",
+			authToken: "secret",
+			expected:  "zoneedit: credentials missing",
+		},
+		{
+			desc:     "missing auth token",
+			user:     "user",
+			expected: "zoneedit: credentials missing",
+		},
+		{
+			desc:     "missing credentials",
+			expected: "zoneedit: credentials missing",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.User = test.user
+			config.AuthToken = test.authToken
+
+			p, err := NewDNSProviderConfig(config)
+
+			if test.expected == "" {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				require.NotNil(t, p.config)
+				require.NotNil(t, p.client)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestLivePresent(t *testing.T) {
+	if !envTest.IsLiveTest() {
+		t.Skip("skipping live test")
+	}
+
+	envTest.RestoreEnv()
+	provider, err := NewDNSProvider()
+	require.NoError(t, err)
+
+	err = provider.Present(envTest.GetDomain(), "", "123d==")
+	require.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+	if !envTest.IsLiveTest() {
+		t.Skip("skipping live test")
+	}
+
+	envTest.RestoreEnv()
+	provider, err := NewDNSProvider()
+	require.NoError(t, err)
+
+	err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+	require.NoError(t, err)
+}