Repository: go-acme/lego
Pull request #1947 (2023-06-29 07:58:28)

Add DNS provider for RcodeZero 
This PR add a DNS provider for RcodeZero (https://www.rcodezero.at) 

API-Documentation available at https://my.rcodezero.at/openapi

ldez: Hello, in order for a PR adding a DNS provider to be accepted, you have to:

- [ ] add a description to your PR
- [ ] be able to maintain this provider
- [ ] have a homogeneous design with the other providers
- [ ] add tests (units)
```shell
make test
```
- [ ] add tests ("live")
https://github.com/go-acme/lego/blob/c938de68f262c057542d44bcb67ef961cefcc60e/providers/dns/cloudflare/cloudflare_test.go#L125-L151

```shell
make test
```
- [ ] add a [provider descriptor](https://github.com/go-acme/lego/blob/master/providers/dns/cloudflare/cloudflare.toml)
- [ ] generate CLI help, documentation, and readme.
```bash
make generate-dns
```
- [ ] be able to do: _(and put the output of this command to a comment in your PR)_
```bash
rm -rf .lego

./lego -m your@email.com --dns YOUR_PROVIDER_NAME -d *.example.com -d example.com -s https://acme-staging-v02.api.letsencrypt.org/directory run
```
Note the wildcard domain is important.
- [ ] pass the linter ([golangci-lint](https://github.com/golangci/golangci-lint#install) must be installed):
```shell
make checks
```
- [ ] do `go mod tidy`
ldez: Hello,

I can see several problems:
- you have copy-paste the ppdns provider but it contains some specific things that you don't need.
- the documentation is about v2 but you have created the implementation on the v1.
- some fixtures files are not used

MikeAT: the API has some special "ACME" routes (with `/api/v1/acme`)  with are not in the offical documenation and which are still (and will be in the future) supported on APIv1 (because they are used by other ACME implementations too).

ldez: Maybe it was a special endpoint in the v1 but you can do the same thing with v2: [Manage RRsets](https://my.rcodezero.at/openapi/#patch-/api/v2/zones/{zone}/rrsets)
ldez: Are you an employee of RcodeZero?
ldez: You seem to be an employee https://www.nic.at/en/team/research-and-development
I will say that we can overcome the missing API documentation in this context.

But the client implementation and the tests still need to be updated. 
MikeAT: Yes, I am an employee and the main developer of the API. 

With the `/api/v1/acme/zones/<zone>/rrsets` route, your are only apple to update the acme_challenge records, so if your API token leaks it can not be used for abuse. The `/api/v2/zones/<zone>/rrsets` calls allows the complete modification for all RRsets in the zone. This is the reason why we implemented the `acme` routes (and keep them stable while we improve the API).





ldez: I updated the code and the documentation to have a homogeneous design.

Can you give me the output of the last unchecked box?
https://github.com/go-acme/lego/pull/1947#issuecomment-1612665530

MikeAT: Thank you very much for the review and the updates.

This is the missing output:

```
❯ rm -rf .lego

❯ ./lego -m nobody@nic.at --dns rcodezero -d "*.le.braunoeder.at"  -d "le.braunoeder.at" -s https://acme-staging-v02.api.letsencrypt.org/directory run
2023/06/29 14:42:43 No key found for account nobody@nic.at. Generating a P256 key.
2023/06/29 14:42:43 Saved key to /home/mib/projects/go/lego/dist/.lego/accounts/acme-staging-v02.api.letsencrypt.org/nobody@nic.at/keys/nobody@nic.at.key


2023/06/29 14:42:43 Please review the TOS at https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf
Do you accept the TOS? Y/n
2023/06/29 14:42:43 [INFO] acme: Registering account for nobody@nic.at
!!!! HEADS UP !!!!

Your account credentials have been saved in your Let's Encrypt
configuration directory at "/home/mib/projects/go/lego/dist/.lego/accounts".

You should make a secure backup of this folder now. This
configuration directory will also contain certificates and
private keys obtained from Let's Encrypt so making regular
backups of this folder is ideal.
2023/06/29 14:42:44 [INFO] [*.le.braunoeder.at, le.braunoeder.at] acme: Obtaining bundled SAN certificate
2023/06/29 14:42:44 [INFO] [*.le.braunoeder.at] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/7095020954
2023/06/29 14:42:44 [INFO] [le.braunoeder.at] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/7095020964
2023/06/29 14:42:44 [INFO] [*.le.braunoeder.at] acme: use dns-01 solver
2023/06/29 14:42:44 [INFO] [le.braunoeder.at] acme: Could not find solver for: tls-alpn-01
2023/06/29 14:42:44 [INFO] [le.braunoeder.at] acme: Could not find solver for: http-01
2023/06/29 14:42:44 [INFO] [le.braunoeder.at] acme: use dns-01 solver
2023/06/29 14:42:44 [INFO] [*.le.braunoeder.at] acme: Preparing to solve DNS-01
2023/06/29 14:42:45 [INFO] [le.braunoeder.at] acme: Preparing to solve DNS-01
2023/06/29 14:42:45 [INFO] [*.le.braunoeder.at] acme: Trying to solve DNS-01
2023/06/29 14:42:45 [INFO] [*.le.braunoeder.at] acme: Checking DNS record propagation using [10.10.7.7:53 10.17.9.3:53 1.1.1.1:53]
2023/06/29 14:42:55 [INFO] Wait for propagation [timeout: 4m0s, interval: 10s]
2023/06/29 14:42:56 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:43:06 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:43:16 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:43:26 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:43:36 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:43:46 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:43:56 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:44:06 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:44:16 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:44:26 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:44:38 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:44:58 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:45:08 [INFO] [*.le.braunoeder.at] acme: Waiting for DNS record propagation.
2023/06/29 14:45:21 [INFO] [*.le.braunoeder.at] The server validated our request
2023/06/29 14:45:21 [INFO] [le.braunoeder.at] acme: Trying to solve DNS-01
2023/06/29 14:45:23 [INFO] [le.braunoeder.at] acme: Checking DNS record propagation using [10.10.7.7:53 10.17.9.3:53 1.1.1.1:53]
2023/06/29 14:45:33 [INFO] Wait for propagation [timeout: 4m0s, interval: 10s]
2023/06/29 14:45:33 [INFO] [le.braunoeder.at] The server validated our request
2023/06/29 14:45:33 [INFO] [*.le.braunoeder.at] acme: Cleaning DNS-01 challenge
2023/06/29 14:45:33 [INFO] [le.braunoeder.at] acme: Cleaning DNS-01 challenge
2023/06/29 14:45:33 [INFO] [*.le.braunoeder.at, le.braunoeder.at] acme: Validations succeeded; requesting certificates
2023/06/29 14:45:34 [INFO] Wait for certificate [timeout: 30s, interval: 500ms]
2023/06/29 14:45:35 [INFO] [*.le.braunoeder.at] Server responded with a certificate.
❯
```
ldez: Everything seems right, I always wait a few days before merging a PR to be able to do a "second" review with fresh eyes.

Patch:
diff --git a/README.md b/README.md
index ab258549..a927691c 100644
--- a/README.md
+++ b/README.md
@@ -75,14 +75,14 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
 | [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/)                      | [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/)                              |
-| [Websupport](https://go-acme.github.io/lego/dns/websupport/)                    | [WEDOS](https://go-acme.github.io/lego/dns/wedos/)                              | [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/)                            |                                                                                 |                                                                                 |
+| [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/)                              | [Websupport](https://go-acme.github.io/lego/dns/websupport/)                    | [WEDOS](https://go-acme.github.io/lego/dns/wedos/)                              | [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/)                 |
+| [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/)                        | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/)                           | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/)                            |                                                                                 |
 
 <!-- END DNS PROVIDERS LIST -->
 
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 5c0b966d..6454f40c 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -102,6 +102,7 @@ func allDNSCodes() string {
 		"plesk",
 		"porkbun",
 		"rackspace",
+		"rcodezero",
 		"regru",
 		"rfc2136",
 		"rimuhosting",
@@ -2001,6 +2002,26 @@ func displayDNSHelp(w io.Writer, name string) error {
 		ew.writeln()
 		ew.writeln(`More information: https://go-acme.github.io/lego/dns/rackspace`)
 
+	case "rcodezero":
+		// generated from: providers/dns/rcodezero/rcodezero.toml
+		ew.writeln(`Configuration for RcodeZero.`)
+		ew.writeln(`Code:	'rcodezero'`)
+		ew.writeln(`Since:	'v4.13'`)
+		ew.writeln()
+
+		ew.writeln(`Credentials:`)
+		ew.writeln(`	- "RCODEZERO_API_TOKEN":	API token`)
+		ew.writeln()
+
+		ew.writeln(`Additional Configuration:`)
+		ew.writeln(`	- "RCODEZERO_HTTP_TIMEOUT":	API request timeout`)
+		ew.writeln(`	- "RCODEZERO_POLLING_INTERVAL":	Time between DNS propagation check`)
+		ew.writeln(`	- "RCODEZERO_PROPAGATION_TIMEOUT":	Maximum waiting time for DNS propagation`)
+		ew.writeln(`	- "RCODEZERO_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/rcodezero`)
+
 	case "regru":
 		// generated from: providers/dns/regru/regru.toml
 		ew.writeln(`Configuration for reg.ru.`)
diff --git a/docs/content/dns/zz_gen_rcodezero.md b/docs/content/dns/zz_gen_rcodezero.md
new file mode 100644
index 00000000..e48c34ca
--- /dev/null
+++ b/docs/content/dns/zz_gen_rcodezero.md
@@ -0,0 +1,74 @@
+---
+title: "RcodeZero"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: rcodezero
+dnsprovider:
+  since:    "v4.13"
+  code:     "rcodezero"
+  url:      "https://www.rcodezero.at/"
+---
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/rcodezero/rcodezero.toml -->
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+
+
+Configuration for [RcodeZero](https://www.rcodezero.at/).
+
+
+<!--more-->
+
+- Code: `rcodezero`
+- Since: v4.13
+
+
+Here is an example bash command using the RcodeZero provider:
+
+```bash
+RCODEZERO_API_TOKEN=<mytoken> \
+lego --email you@example.com --dns rcodezero --domains my.example.org run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `RCODEZERO_API_TOKEN` | API token |
+
+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 |
+|--------------------------------|-------------|
+| `RCODEZERO_HTTP_TIMEOUT` | API request timeout |
+| `RCODEZERO_POLLING_INTERVAL` | Time between DNS propagation check |
+| `RCODEZERO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `RCODEZERO_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" >}}).
+
+## Description
+
+Generate your API Token via https://my.rcodezero.at with the `ACME` permissions.
+This are special tokens with limited access for ACME requests only.
+
+RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can take up to 2 minutes.
+
+
+
+
+## More information
+
+- [API documentation](https://my.rcodezero.at/openapi)
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/rcodezero/rcodezero.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 56c27db3..6e344a34 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -136,7 +136,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, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudxns, conoha, constellix, derak, desec, designate, digitalocean, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpreq, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, iwantmyname, joker, liara, lightsail, linode, liquidweb, loopia, luadns, manual, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, servercow, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, vscale, vultr, websupport, wedos, yandex, yandexcloud, zoneee, zonomi
+  acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudxns, conoha, constellix, derak, desec, designate, digitalocean, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpreq, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, iwantmyname, joker, liara, lightsail, linode, liquidweb, loopia, luadns, manual, 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, websupport, wedos, yandex, yandexcloud, zoneee, zonomi
 
 More information: https://go-acme.github.io/lego/dns
 """
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 885e3a9d..7aeeb33f 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -93,6 +93,7 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/plesk"
 	"github.com/go-acme/lego/v4/providers/dns/porkbun"
 	"github.com/go-acme/lego/v4/providers/dns/rackspace"
+	"github.com/go-acme/lego/v4/providers/dns/rcodezero"
 	"github.com/go-acme/lego/v4/providers/dns/regru"
 	"github.com/go-acme/lego/v4/providers/dns/rfc2136"
 	"github.com/go-acme/lego/v4/providers/dns/rimuhosting"
@@ -305,6 +306,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
 		return porkbun.NewDNSProvider()
 	case "rackspace":
 		return rackspace.NewDNSProvider()
+	case "rcodezero":
+		return rcodezero.NewDNSProvider()
 	case "regru":
 		return regru.NewDNSProvider()
 	case "rfc2136":
diff --git a/providers/dns/rcodezero/internal/client.go b/providers/dns/rcodezero/internal/client.go
new file mode 100644
index 00000000..d37fec2d
--- /dev/null
+++ b/providers/dns/rcodezero/internal/client.go
@@ -0,0 +1,114 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"github.com/miekg/dns"
+)
+
+const defaultBaseURL = "https://my.rcodezero.at/api"
+
+const authorizationHeader = "Authorization"
+
+// Client for the RcodeZero API.
+type Client struct {
+	apiToken string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiToken string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		apiToken:   apiToken,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c *Client) UpdateRecords(ctx context.Context, authZone string, sets []UpdateRRSet) (*APIResponse, error) {
+	endpoint := c.baseURL.JoinPath("v1", "acme", "zones", strings.TrimSuffix(dns.Fqdn(authZone), "."), "rrsets")
+
+	req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets)
+	if err != nil {
+		return nil, err
+	}
+
+	return c.do(req)
+}
+
+func (c *Client) do(req *http.Request) (*APIResponse, error) {
+	req.Header.Set(authorizationHeader, "Bearer "+c.apiToken)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return nil, parseError(req, resp)
+	}
+
+	result := &APIResponse{}
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return result, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := &APIResponse{}
+	err := json.Unmarshal(raw, errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("[status code: %d] %w", resp.StatusCode, errAPI)
+}
diff --git a/providers/dns/rcodezero/internal/fixtures/error.json b/providers/dns/rcodezero/internal/fixtures/error.json
new file mode 100644
index 00000000..6257f755
--- /dev/null
+++ b/providers/dns/rcodezero/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+  "status": "failed",
+  "message": "A human readable error message"
+}
diff --git a/providers/dns/rcodezero/internal/fixtures/rrsets-response.json b/providers/dns/rcodezero/internal/fixtures/rrsets-response.json
new file mode 100644
index 00000000..83bdfa1f
--- /dev/null
+++ b/providers/dns/rcodezero/internal/fixtures/rrsets-response.json
@@ -0,0 +1,4 @@
+{
+  "status": "ok",
+  "message": "RRsets updated"
+}
diff --git a/providers/dns/rcodezero/internal/types.go b/providers/dns/rcodezero/internal/types.go
new file mode 100644
index 00000000..6fdffb81
--- /dev/null
+++ b/providers/dns/rcodezero/internal/types.go
@@ -0,0 +1,25 @@
+package internal
+
+import "fmt"
+
+type UpdateRRSet struct {
+	Name       string   `json:"name"`
+	Type       string   `json:"type"`
+	ChangeType string   `json:"changetype"`
+	Records    []Record `json:"records"`
+	TTL        int      `json:"ttl"`
+}
+
+type Record struct {
+	Content  string `json:"content"`
+	Disabled bool   `json:"disabled"`
+}
+
+type APIResponse struct {
+	Status  string `json:"status"`
+	Message string `json:"message"`
+}
+
+func (a APIResponse) Error() string {
+	return fmt.Sprintf("%s: %s", a.Status, a.Message)
+}
diff --git a/providers/dns/rcodezero/rcodezero.go b/providers/dns/rcodezero/rcodezero.go
new file mode 100644
index 00000000..b19563e5
--- /dev/null
+++ b/providers/dns/rcodezero/rcodezero.go
@@ -0,0 +1,145 @@
+// Package rcodezero implements a DNS provider for solving the DNS-01 challenge using RcodeZero Anycast network.
+package rcodezero
+
+import (
+	"context"
+	"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/rcodezero/internal"
+)
+
+// Environment variables names.
+const (
+	envNamespace = "RCODEZERO_"
+
+	EnvAPIToken = envNamespace + "API_TOKEN"
+
+	EnvTTL                = envNamespace + "TTL"
+	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 {
+	APIToken           string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	TTL                int
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+	return &Config{
+		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 240*time.Second),
+		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
+		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 RcodeZero.
+// Credentials must be passed in the environment variable:
+// RCODEZERO_API_URL and RCODEZERO_API_TOKEN.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get(EnvAPIToken)
+	if err != nil {
+		return nil, fmt.Errorf("rcodezero: %w", err)
+	}
+
+	config := NewDefaultConfig()
+	config.APIToken = values[EnvAPIToken]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for RcodeZero.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("rcodezero: the configuration of the DNS provider is nil")
+	}
+
+	if config.APIToken == "" {
+		return nil, errors.New("rcodezero: API token missing")
+	}
+
+	client := internal.NewClient(config.APIToken)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, 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, token, keyAuth string) error {
+	info := dns01.GetChallengeInfo(domain, keyAuth)
+
+	ctx := context.Background()
+
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("rcodezero: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
+	}
+
+	rrSet := []internal.UpdateRRSet{{
+		Name:       info.EffectiveFQDN,
+		ChangeType: "update",
+		Type:       "TXT",
+		TTL:        d.config.TTL,
+		Records:    []internal.Record{{Content: `"` + info.Value + `"`}},
+	}}
+
+	_, err = d.client.UpdateRecords(ctx, authZone, rrSet)
+	if err != nil {
+		return fmt.Errorf("rcodezero: %w", err)
+	}
+
+	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)
+
+	ctx := context.Background()
+
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("rcodezero: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
+	}
+
+	rrSet := []internal.UpdateRRSet{{
+		Name:       info.EffectiveFQDN,
+		Type:       "TXT",
+		ChangeType: "delete",
+	}}
+
+	_, err = d.client.UpdateRecords(ctx, authZone, rrSet)
+	if err != nil {
+		return fmt.Errorf("rcodezero: %w", err)
+	}
+
+	return nil
+}
diff --git a/providers/dns/rcodezero/rcodezero.toml b/providers/dns/rcodezero/rcodezero.toml
new file mode 100644
index 00000000..ee475d20
--- /dev/null
+++ b/providers/dns/rcodezero/rcodezero.toml
@@ -0,0 +1,33 @@
+Name = "RcodeZero"
+Description = ''''''
+URL = "https://www.rcodezero.at/"
+Code = "rcodezero"
+Since = "v4.13"
+
+Example = '''
+RCODEZERO_API_TOKEN=<mytoken> \
+lego --email you@example.com --dns rcodezero --domains my.example.org run
+'''
+
+Additional = '''
+## Description
+
+Generate your API Token via https://my.rcodezero.at with the `ACME` permissions.
+This are special tokens with limited access for ACME requests only.
+
+RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can take up to 2 minutes.
+
+'''
+
+[Configuration]
+  [Configuration.Credentials]
+    RCODEZERO_API_TOKEN = "API token"
+  [Configuration.Additional]
+    RCODEZERO_POLLING_INTERVAL = "Time between DNS propagation check"
+    RCODEZERO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
+    RCODEZERO_TTL = "The TTL of the TXT record used for the DNS challenge"
+    RCODEZERO_HTTP_TIMEOUT = "API request timeout"
+
+[Links]
+  # Note: the API endpoint used inside the client is not documented.
+  API = "https://my.rcodezero.at/openapi"


Test patch:
diff --git a/providers/dns/rcodezero/internal/client_test.go b/providers/dns/rcodezero/internal/client_test.go
new file mode 100644
index 00000000..c19e6e5b
--- /dev/null
+++ b/providers/dns/rcodezero/internal/client_test.go
@@ -0,0 +1,96 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file 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 != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		apiToken := req.Header.Get(authorizationHeader)
+		if apiToken != "Bearer secret" {
+			http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiToken), http.StatusBadRequest)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	client := NewClient("secret")
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_UpdateRecords_error(t *testing.T) {
+	client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusUnprocessableEntity, "error.json")
+
+	rrSet := []UpdateRRSet{{
+		Name:       "acme.example.org.",
+		ChangeType: "add",
+		Type:       "TXT",
+		Records:    []Record{{Content: `"my-acme-challenge"`}},
+	}}
+
+	resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet)
+	require.ErrorAs(t, err, new(*APIResponse))
+	assert.Nil(t, resp)
+}
+
+func TestClient_UpdateRecords(t *testing.T) {
+	client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusOK, "rrsets-response.json")
+
+	rrSet := []UpdateRRSet{{
+		Name:       "acme.example.org.",
+		ChangeType: "add",
+		Type:       "TXT",
+		Records:    []Record{{Content: `"my-acme-challenge"`}},
+	}}
+
+	resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet)
+	require.NoError(t, err)
+
+	expected := &APIResponse{Status: "ok", Message: "RRsets updated"}
+
+	assert.Equal(t, expected, resp)
+}
diff --git a/providers/dns/rcodezero/rcodezero_test.go b/providers/dns/rcodezero/rcodezero_test.go
new file mode 100644
index 00000000..1f094607
--- /dev/null
+++ b/providers/dns/rcodezero/rcodezero_test.go
@@ -0,0 +1,105 @@
+package rcodezero
+
+import (
+	"testing"
+
+	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+	EnvAPIToken).
+	WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				EnvAPIToken: "123",
+			},
+		},
+		{
+			desc: "missing credentials",
+			envVars: map[string]string{
+				EnvAPIToken: "",
+			},
+			expected: "rcodezero: some credentials information are missing: RCODEZERO_API_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)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		apiToken string
+		expected string
+	}{
+		{
+			desc:     "success",
+			apiToken: "123",
+		},
+		{
+			desc:     "missing credentials",
+			expected: "rcodezero: API token missing",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.APIToken = test.apiToken
+
+			p, err := NewDNSProviderConfig(config)
+
+			if test.expected == "" {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				require.NotNil(t, p.config)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestLivePresentAndCleanup(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)
+
+	err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+	require.NoError(t, err)
+}