diff --git a/.github/ISSUE_TEMPLATE/new_dns_provider.yml b/.github/ISSUE_TEMPLATE/new_dns_provider.yml
index cfd6e5c8c..b319bc287 100644
--- a/.github/ISSUE_TEMPLATE/new_dns_provider.yml
+++ b/.github/ISSUE_TEMPLATE/new_dns_provider.yml
@@ -14,9 +14,15 @@ body:
required: true
- label: Yes, I know that the lego maintainers don't have an account in all DNS providers in the world.
required: true
+
+ - type: checkboxes
+ id: pr
+ attributes:
+ label: Implementation
+ options:
- label: Yes, I'm able to create a pull request and be able to maintain the implementation.
required: false
- - label: Yes, I'm able to test an implementation if someone creates a pull request to add the support of this DNS provider.
+ - label: Yes, I can test an implementation with the help of the maintainers if someone creates a pull request.
required: false
- type: dropdown
diff --git a/README.md b/README.md
index 9925979bd..e90e94962 100644
--- a/README.md
+++ b/README.md
@@ -73,70 +73,75 @@ If your DNS provider is not supported, please open an [issue](https://github.com
| Amazon Route 53 |
Anexia CloudDNS |
+ ANS SafeDNS |
ArtFiles |
- ArvanCloud |
+ | ArvanCloud |
Aurora DNS |
Autodns |
Axelname |
- Azion |
+ | Azion |
Azure (deprecated) |
Azure DNS |
Baidu Cloud |
- Beget.com |
+ | Beget.com |
Binary Lane |
Bindman |
Bluecat |
- Bluecat v2 |
+ | Bluecat v2 |
BookMyName |
Brandit (deprecated) |
Bunny |
- Checkdomain |
+ | Checkdomain |
Civo |
Cloud.ru |
CloudDNS |
- Cloudflare |
+ | Cloudflare |
ClouDNS |
CloudXNS (Deprecated) |
ConoHa v2 |
- ConoHa v3 |
+ | ConoHa v3 |
Constellix |
Core-Networks |
CPanel/WHM |
- DDnss (DynDNS Service) |
+ | Czechia |
+ DDnss (DynDNS Service) |
Derak Cloud |
deSEC.io |
+
| Designate DNSaaS for Openstack |
Digital Ocean |
-
| DirectAdmin |
DNS Made Easy |
+
| DNSExit |
dnsHome.de |
-
| DNSimple |
DNSPod (deprecated) |
+
| Domain Offensive (do.de) |
Domeneshop |
-
| DreamHost |
Duck DNS |
+
| Dyn |
DynDnsFree.de |
-
| Dynu |
EasyDNS |
+
| EdgeCenter |
Efficient IP |
-
| Epik |
+ EuroDNS |
+
+ | Excedo |
Exoscale |
External program |
F5 XC |
@@ -266,35 +271,35 @@ If your DNS provider is not supported, please open an [issue](https://github.com
TodayNIC/时代互联 |
TransIP |
- | UKFast SafeDNS |
Ultradns |
United-Domains |
Variomedia |
-
| VegaDNS |
+
| Vercel |
Versio.[nl|eu|uk] |
VinylDNS |
-
| Virtualname |
+
| VK Cloud |
Volcano Engine/火山引擎 |
Vscale |
-
| Vultr |
+
| webnames.ca |
webnames.ru |
Websupport |
-
| WEDOS |
+
| West.cn/西部数码 |
Yandex 360 |
Yandex Cloud |
-
| Yandex PDD |
+
| Zone.ee |
ZoneEdit |
Zonomi |
+ |
diff --git a/acme/api/internal/sender/useragent.go b/acme/api/internal/sender/useragent.go
index feda18cc7..51a1b4770 100644
--- a/acme/api/internal/sender/useragent.go
+++ b/acme/api/internal/sender/useragent.go
@@ -9,5 +9,5 @@ const (
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
- ourUserAgentComment = "release"
+ ourUserAgentComment = "detach"
)
diff --git a/challenge/resolver/errors.go b/challenge/resolver/errors.go
index 6a859922c..65a6ccdb7 100644
--- a/challenge/resolver/errors.go
+++ b/challenge/resolver/errors.go
@@ -3,6 +3,8 @@ package resolver
import (
"bytes"
"fmt"
+ "maps"
+ "slices"
"sort"
)
@@ -25,3 +27,7 @@ func (e obtainError) Error() string {
return buffer.String()
}
+
+func (e obtainError) Unwrap() []error {
+ return slices.AppendSeq(make([]error, 0, len(e)), maps.Values(e))
+}
diff --git a/challenge/resolver/errors_test.go b/challenge/resolver/errors_test.go
new file mode 100644
index 000000000..d4ab3c481
--- /dev/null
+++ b/challenge/resolver/errors_test.go
@@ -0,0 +1,70 @@
+package resolver
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/go-acme/lego/v4/acme"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_obtainError_Error(t *testing.T) {
+ err := obtainError{
+ "a": &acme.ProblemDetails{Type: "001"},
+ "b": errors.New("oops"),
+ "c": errors.New("I did it again"),
+ }
+
+ require.EqualError(t, err, `error: one or more domains had a problem:
+[a] acme: error: 0 :: 001 ::
+[b] oops
+[c] I did it again
+`)
+}
+
+func Test_obtainError_Unwrap(t *testing.T) {
+ testCases := []struct {
+ desc string
+ err obtainError
+ assert assert.BoolAssertionFunc
+ }{
+ {
+ desc: "one ok",
+ err: obtainError{
+ "a": &acme.ProblemDetails{},
+ "b": errors.New("oops"),
+ "c": errors.New("I did it again"),
+ },
+ assert: assert.True,
+ },
+ {
+ desc: "all ok",
+ err: obtainError{
+ "a": &acme.ProblemDetails{Type: "001"},
+ "b": &acme.ProblemDetails{Type: "002"},
+ "c": &acme.ProblemDetails{Type: "002"},
+ },
+ assert: assert.True,
+ },
+ {
+ desc: "nope",
+ err: obtainError{
+ "a": errors.New("hello"),
+ "b": errors.New("oops"),
+ "c": errors.New("I did it again"),
+ },
+ assert: assert.False,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ var pd *acme.ProblemDetails
+
+ test.assert(t, errors.As(test.err, &pd))
+ })
+ }
+}
diff --git a/cmd/lego/zz_gen_version.go b/cmd/lego/zz_gen_version.go
index f0ca21326..cf9ad00ef 100644
--- a/cmd/lego/zz_gen_version.go
+++ b/cmd/lego/zz_gen_version.go
@@ -2,7 +2,7 @@
package main
-const defaultVersion = "v4.32.0+dev-release"
+const defaultVersion = "v4.32.0+dev-detach"
var version = ""
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 161729c79..f73f3920b 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -49,6 +49,7 @@ func allDNSCodes() string {
"constellix",
"corenetworks",
"cpanel",
+ "czechia",
"ddnss",
"derak",
"desec",
@@ -73,6 +74,8 @@ func allDNSCodes() string {
"edgeone",
"efficientip",
"epik",
+ "eurodns",
+ "excedo",
"exec",
"exoscale",
"f5xc",
@@ -1026,6 +1029,26 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cpanel`)
+ case "czechia":
+ // generated from: providers/dns/czechia/czechia.toml
+ ew.writeln(`Configuration for Czechia.`)
+ ew.writeln(`Code: 'czechia'`)
+ ew.writeln(`Since: 'v4.33.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "CZECHIA_TOKEN": Authorization token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "CZECHIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CZECHIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CZECHIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "CZECHIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/czechia`)
+
case "ddnss":
// generated from: providers/dns/ddnss/ddnss.toml
ew.writeln(`Configuration for DDnss (DynDNS Service).`)
@@ -1541,6 +1564,48 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/epik`)
+ case "eurodns":
+ // generated from: providers/dns/eurodns/eurodns.toml
+ ew.writeln(`Configuration for EuroDNS.`)
+ ew.writeln(`Code: 'eurodns'`)
+ ew.writeln(`Since: 'v4.33.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EURODNS_API_KEY": API key`)
+ ew.writeln(` - "EURODNS_APP_ID": Application ID`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EURODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EURODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EURODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "EURODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/eurodns`)
+
+ case "excedo":
+ // generated from: providers/dns/excedo/excedo.toml
+ ew.writeln(`Configuration for Excedo.`)
+ ew.writeln(`Code: 'excedo'`)
+ ew.writeln(`Since: 'v4.33.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EXCEDO_API_KEY": API key`)
+ ew.writeln(` - "EXCEDO_API_URL": API base URL`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EXCEDO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EXCEDO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "EXCEDO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "EXCEDO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/excedo`)
+
case "exec":
// generated from: providers/dns/exec/exec.toml
ew.writeln(`Configuration for External program.`)
@@ -2394,6 +2459,7 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`)
ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
@@ -3373,7 +3439,7 @@ func displayDNSHelp(w io.Writer, name string) error {
case "safedns":
// generated from: providers/dns/safedns/safedns.toml
- ew.writeln(`Configuration for UKFast SafeDNS.`)
+ ew.writeln(`Configuration for ANS SafeDNS.`)
ew.writeln(`Code: 'safedns'`)
ew.writeln(`Since: 'v4.6.0'`)
ew.writeln()
diff --git a/docs/content/dns/zz_gen_czechia.md b/docs/content/dns/zz_gen_czechia.md
new file mode 100644
index 000000000..7b1cdd1ae
--- /dev/null
+++ b/docs/content/dns/zz_gen_czechia.md
@@ -0,0 +1,67 @@
+---
+title: "Czechia"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: czechia
+dnsprovider:
+ since: "v4.33.0"
+ code: "czechia"
+ url: "https://www.czechia.com/"
+---
+
+
+
+
+
+
+Configuration for [Czechia](https://www.czechia.com/).
+
+
+
+
+- Code: `czechia`
+- Since: v4.33.0
+
+
+Here is an example bash command using the Czechia provider:
+
+```bash
+CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns czechia -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `CZECHIA_TOKEN` | Authorization 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 |
+|--------------------------------|-------------|
+| `CZECHIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CZECHIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CZECHIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `CZECHIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+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://api.czechia.com/swagger/index.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_eurodns.md b/docs/content/dns/zz_gen_eurodns.md
new file mode 100644
index 000000000..cb5a0418d
--- /dev/null
+++ b/docs/content/dns/zz_gen_eurodns.md
@@ -0,0 +1,69 @@
+---
+title: "EuroDNS"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: eurodns
+dnsprovider:
+ since: "v4.33.0"
+ code: "eurodns"
+ url: "https://www.eurodns.com/"
+---
+
+
+
+
+
+
+Configuration for [EuroDNS](https://www.eurodns.com/).
+
+
+
+
+- Code: `eurodns`
+- Since: v4.33.0
+
+
+Here is an example bash command using the EuroDNS provider:
+
+```bash
+EURODNS_APP_ID="xxx" \
+EURODNS_API_KEY="yyy" \
+lego --dns eurodns -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EURODNS_API_KEY` | API key |
+| `EURODNS_APP_ID` | Application 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 |
+|--------------------------------|-------------|
+| `EURODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EURODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EURODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `EURODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
+
+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://docapi.eurodns.com/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_excedo.md b/docs/content/dns/zz_gen_excedo.md
new file mode 100644
index 000000000..456e6f60a
--- /dev/null
+++ b/docs/content/dns/zz_gen_excedo.md
@@ -0,0 +1,69 @@
+---
+title: "Excedo"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: excedo
+dnsprovider:
+ since: "v4.33.0"
+ code: "excedo"
+ url: "https://excedo.se/"
+---
+
+
+
+
+
+
+Configuration for [Excedo](https://excedo.se/).
+
+
+
+
+- Code: `excedo`
+- Since: v4.33.0
+
+
+Here is an example bash command using the Excedo provider:
+
+```bash
+EXCEDO_API_KEY=your-api-key \
+EXCEDO_API_URL=your-base-url \
+lego --dns excedo -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EXCEDO_API_KEY` | API key |
+| `EXCEDO_API_URL` | API base URL |
+
+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 |
+|--------------------------------|-------------|
+| `EXCEDO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EXCEDO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `EXCEDO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `EXCEDO_TTL` | The TTL of the TXT record used for the DNS challenge 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](none)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_liara.md b/docs/content/dns/zz_gen_liara.md
index 8a6ddbd99..658ce8077 100644
--- a/docs/content/dns/zz_gen_liara.md
+++ b/docs/content/dns/zz_gen_liara.md
@@ -50,6 +50,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `LIARA_TEAM_ID` | The team ID to access services in a team |
| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_safedns.md b/docs/content/dns/zz_gen_safedns.md
index e040a8a9f..4c20fca6a 100644
--- a/docs/content/dns/zz_gen_safedns.md
+++ b/docs/content/dns/zz_gen_safedns.md
@@ -1,12 +1,12 @@
---
-title: "UKFast SafeDNS"
+title: "ANS SafeDNS"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: safedns
dnsprovider:
since: "v4.6.0"
code: "safedns"
- url: "https://www.ukfast.co.uk/dns-hosting.html"
+ url: "https://www.ans.co.uk/"
---
@@ -14,7 +14,7 @@ dnsprovider:
-Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html).
+Configuration for [ANS SafeDNS](https://www.ans.co.uk/).
@@ -23,7 +23,7 @@ Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html).
- Since: v4.6.0
-Here is an example bash command using the UKFast SafeDNS provider:
+Here is an example bash command using the ANS SafeDNS provider:
```bash
SAFEDNS_AUTH_TOKEN=xxxxxx \
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index 925ef0b21..139143b17 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -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, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
+ acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, czechia, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, eurodns, excedo, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""
diff --git a/providers/dns/czechia/czechia.go b/providers/dns/czechia/czechia.go
new file mode 100644
index 000000000..3ff397c35
--- /dev/null
+++ b/providers/dns/czechia/czechia.go
@@ -0,0 +1,159 @@
+// Package czechia implements a DNS provider for solving the DNS-01 challenge using Czechia.
+package czechia
+
+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/czechia/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "CZECHIA_"
+
+ EnvToken = envNamespace + "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 {
+ Token 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, 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 Czechia.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("czechia: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Token = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Czechia.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("czechia: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Token)
+ if err != nil {
+ return nil, fmt.Errorf("czechia: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("czechia: %w", err)
+ }
+
+ record := internal.TXTRecord{
+ Hostname: subDomain,
+ Text: info.Value,
+ TTL: d.config.TTL,
+ PublishZone: 1,
+ }
+
+ err = d.client.AddTXTRecord(ctx, dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("czechia: add TXT record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("czechia: %w", err)
+ }
+
+ record := internal.TXTRecord{
+ Hostname: subDomain,
+ Text: info.Value,
+ TTL: d.config.TTL,
+ PublishZone: 1,
+ }
+
+ err = d.client.DeleteTXTRecord(ctx, dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("czechia: delete TXT record: %w", err)
+ }
+
+ 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/czechia/czechia.toml b/providers/dns/czechia/czechia.toml
new file mode 100644
index 000000000..2a66d2054
--- /dev/null
+++ b/providers/dns/czechia/czechia.toml
@@ -0,0 +1,22 @@
+Name = "Czechia"
+Description = ''''''
+URL = "https://www.czechia.com/"
+Code = "czechia"
+Since = "v4.33.0"
+
+Example = '''
+CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns czechia -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ CZECHIA_TOKEN = "Authorization token"
+ [Configuration.Additional]
+ CZECHIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CZECHIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ CZECHIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ CZECHIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://api.czechia.com/swagger/index.html"
diff --git a/providers/dns/czechia/czechia_test.go b/providers/dns/czechia/czechia_test.go
new file mode 100644
index 000000000..7d9a2676c
--- /dev/null
+++ b/providers/dns/czechia/czechia_test.go
@@ -0,0 +1,165 @@
+package czechia
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "czechia: some credentials information are missing: CZECHIA_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
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "czechia: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Token = test.token
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Token = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("AuthorizationToken", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/czechia/internal/client.go b/providers/dns/czechia/internal/client.go
new file mode 100644
index 000000000..f3e0e462e
--- /dev/null
+++ b/providers/dns/czechia/internal/client.go
@@ -0,0 +1,124 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.czechia.com/api"
+
+const authorizationTokenHeader = "AuthorizationToken"
+
+// Client the Czechia API client.
+type Client struct {
+ token string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(token string) (*Client, error) {
+ if token == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ token: token,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, domain string, record TXTRecord) error {
+ endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) DeleteTXTRecord(ctx context.Context, domain string, record TXTRecord) error {
+ endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT")
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set(authorizationTokenHeader, c.token)
+
+ 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)
+ }
+
+ 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
+}
+
+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
+}
diff --git a/providers/dns/czechia/internal/client_test.go b/providers/dns/czechia/internal/client_test.go
new file mode 100644
index 000000000..c6f1141c5
--- /dev/null
+++ b/providers/dns/czechia/internal/client_test.go
@@ -0,0 +1,67 @@
+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[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(authorizationTokenHeader, "secret"),
+ )
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ record := TXTRecord{
+ Hostname: "_acme-challenge",
+ Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ PublishZone: 1,
+ }
+
+ err := client.AddTXTRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ record := TXTRecord{
+ Hostname: "_acme-challenge",
+ Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ PublishZone: 1,
+ }
+
+ err := client.DeleteTXTRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/czechia/internal/fixtures/add_txt_record-request.json b/providers/dns/czechia/internal/fixtures/add_txt_record-request.json
new file mode 100644
index 000000000..ed5830093
--- /dev/null
+++ b/providers/dns/czechia/internal/fixtures/add_txt_record-request.json
@@ -0,0 +1,6 @@
+{
+ "hostName": "_acme-challenge",
+ "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "publishZone": 1
+}
diff --git a/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json b/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json
new file mode 100644
index 000000000..ed5830093
--- /dev/null
+++ b/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json
@@ -0,0 +1,6 @@
+{
+ "hostName": "_acme-challenge",
+ "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "publishZone": 1
+}
diff --git a/providers/dns/czechia/internal/types.go b/providers/dns/czechia/internal/types.go
new file mode 100644
index 000000000..f4a9bfef7
--- /dev/null
+++ b/providers/dns/czechia/internal/types.go
@@ -0,0 +1,8 @@
+package internal
+
+type TXTRecord struct {
+ Hostname string `json:"hostName,omitempty"`
+ Text string `json:"text,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ PublishZone int `json:"publishZone,omitempty"`
+}
diff --git a/providers/dns/eurodns/eurodns.go b/providers/dns/eurodns/eurodns.go
new file mode 100644
index 000000000..21ff3c3a9
--- /dev/null
+++ b/providers/dns/eurodns/eurodns.go
@@ -0,0 +1,197 @@
+// Package eurodns implements a DNS provider for solving the DNS-01 challenge using EuroDNS.
+package eurodns
+
+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/eurodns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EURODNS_"
+
+ EnvApplicationID = envNamespace + "APP_ID"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ 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 {
+ ApplicationID string
+ APIKey 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, internal.DefaultTTL),
+ 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 EuroDNS.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvApplicationID, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("eurodns: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ApplicationID = values[EnvApplicationID]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for EuroDNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("eurodns: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.ApplicationID, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("eurodns: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 {
+ ctx := context.Background()
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: %w", err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.client.GetZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: get zone: %w", err)
+ }
+
+ zone.Records = append(zone.Records, internal.Record{
+ Type: "TXT",
+ Host: subDomain,
+ TTL: internal.TTLRounder(d.config.TTL),
+ RData: info.Value,
+ })
+
+ validation, err := d.client.ValidateZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: validate zone: %w", err)
+ }
+
+ if validation.Report != nil && !validation.Report.IsValid {
+ return fmt.Errorf("eurodns: validation report: %w", validation.Report)
+ }
+
+ err = d.client.SaveZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: save zone: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: %w", err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.client.GetZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: get zone: %w", err)
+ }
+
+ var recordsToKeep []internal.Record
+
+ for _, record := range zone.Records {
+ if record.Type == "TXT" && record.Host == subDomain && record.RData == info.Value {
+ continue
+ }
+
+ recordsToKeep = append(recordsToKeep, record)
+ }
+
+ zone.Records = recordsToKeep
+
+ validation, err := d.client.ValidateZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: validate zone: %w", err)
+ }
+
+ if validation.Report != nil && !validation.Report.IsValid {
+ return fmt.Errorf("eurodns: validation report: %w", validation.Report)
+ }
+
+ err = d.client.SaveZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: save zone: %w", err)
+ }
+
+ 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/eurodns/eurodns.toml b/providers/dns/eurodns/eurodns.toml
new file mode 100644
index 000000000..302b15d00
--- /dev/null
+++ b/providers/dns/eurodns/eurodns.toml
@@ -0,0 +1,24 @@
+Name = "EuroDNS"
+Description = ''''''
+URL = "https://www.eurodns.com/"
+Code = "eurodns"
+Since = "v4.33.0"
+
+Example = '''
+EURODNS_APP_ID="xxx" \
+EURODNS_API_KEY="yyy" \
+lego --dns eurodns -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EURODNS_APP_ID = "Application ID"
+ EURODNS_API_KEY = "API key"
+ [Configuration.Additional]
+ EURODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EURODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EURODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ EURODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docapi.eurodns.com/"
diff --git a/providers/dns/eurodns/eurodns_test.go b/providers/dns/eurodns/eurodns_test.go
new file mode 100644
index 000000000..abbb4717e
--- /dev/null
+++ b/providers/dns/eurodns/eurodns_test.go
@@ -0,0 +1,215 @@
+package eurodns
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/eurodns/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvApplicationID, EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvApplicationID: "abc",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing application ID",
+ envVars: map[string]string{
+ EnvApplicationID: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "eurodns: some credentials information are missing: EURODNS_APP_ID",
+ },
+ {
+ desc: "missing API secret",
+ envVars: map[string]string{
+ EnvApplicationID: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "eurodns: some credentials information are missing: EURODNS_APP_ID",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "eurodns: some credentials information are missing: EURODNS_APP_ID,EURODNS_API_KEY",
+ },
+ }
+
+ 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
+ appID string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ appID: "abc",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing application ID",
+ expected: "eurodns: credentials missing",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing API secret",
+ expected: "eurodns: credentials missing",
+ appID: "abc",
+ },
+ {
+ desc: "missing credentials",
+ expected: "eurodns: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ApplicationID = test.appID
+ config.APIKey = test.apiKey
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.ApplicationID = "abc"
+ config.HTTPClient = server.Client()
+
+ provider, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ provider.client.BaseURL, _ = url.Parse(server.URL)
+
+ return provider, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(internal.HeaderAppID, "abc").
+ With(internal.HeaderAPIKey, "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromInternal("zone_get.json"),
+ ).
+ Route("POST /example.com/check",
+ servermock.ResponseFromInternal("zone_add_validate_ok.json"),
+ servermock.CheckRequestJSONBodyFromInternal("zone_add.json"),
+ ).
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromInternal("zone_add.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromInternal("zone_add.json"),
+ ).
+ Route("POST /example.com/check",
+ servermock.ResponseFromInternal("zone_remove.json"),
+ servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"),
+ ).
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/eurodns/internal/client.go b/providers/dns/eurodns/internal/client.go
new file mode 100644
index 000000000..1ebf8d143
--- /dev/null
+++ b/providers/dns/eurodns/internal/client.go
@@ -0,0 +1,199 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://rest-api.eurodns.com/dns-zones/"
+
+const (
+ HeaderAppID = "X-APP-ID"
+ HeaderAPIKey = "X-API-KEY"
+)
+
+// Client the EuroDNS API client.
+type Client struct {
+ appID string
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(appID, apiKey string) (*Client, error) {
+ if appID == "" || apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ appID: appID,
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// GetZone gets a DNS Zone.
+// https://docapi.eurodns.com/#/dnsprovider/getdnszone
+func (c *Client) GetZone(ctx context.Context, domain string) (*Zone, error) {
+ endpoint := c.BaseURL.JoinPath(domain)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Zone{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// SaveZone saves a DNS Zone.
+// https://docapi.eurodns.com/#/dnsprovider/savednszone
+func (c *Client) SaveZone(ctx context.Context, domain string, zone *Zone) error {
+ endpoint := c.BaseURL.JoinPath(domain)
+
+ if len(zone.URLForwards) == 0 {
+ zone.URLForwards = make([]URLForward, 0)
+ }
+
+ if len(zone.MailForwards) == 0 {
+ zone.MailForwards = make([]MailForward, 0)
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+// ValidateZone validates DNS Zone.
+// https://docapi.eurodns.com/#/dnsprovider/checkdnszone
+func (c *Client) ValidateZone(ctx context.Context, domain string, zone *Zone) (*Zone, error) {
+ endpoint := c.BaseURL.JoinPath(domain, "check")
+
+ if len(zone.URLForwards) == 0 {
+ zone.URLForwards = make([]URLForward, 0)
+ }
+
+ if len(zone.MailForwards) == 0 {
+ zone.MailForwards = make([]MailForward, 0)
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Zone{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set(HeaderAppID, c.appID)
+ req.Header.Set(HeaderAPIKey, c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(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
+}
+
+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)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return fmt.Errorf("%d: %w", resp.StatusCode, &errAPI)
+}
+
+const DefaultTTL = 600
+
+// TTLRounder rounds the given TTL in seconds to the next accepted value.
+// Accepted TTL values are: 600, 900, 1800,3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800.
+func TTLRounder(ttl int) int {
+ for _, validTTL := range []int{DefaultTTL, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800} {
+ if ttl <= validTTL {
+ return validTTL
+ }
+ }
+
+ return DefaultTTL
+}
diff --git a/providers/dns/eurodns/internal/client_test.go b/providers/dns/eurodns/internal/client_test.go
new file mode 100644
index 000000000..68d1fda84
--- /dev/null
+++ b/providers/dns/eurodns/internal/client_test.go
@@ -0,0 +1,310 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("abc", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(HeaderAppID, "abc").
+ With(HeaderAPIKey, "secret"),
+ )
+}
+
+func TestClient_GetZone(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromFixture("zone_get.json"),
+ ).
+ Build(t)
+
+ zone, err := client.GetZone(context.Background(), "example.com")
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: slices.Concat([]Record{fakeARecord()}),
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_GetZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ _, err := client.GetZone(context.Background(), "example.com")
+ require.Error(t, err)
+
+ require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key")
+}
+
+func TestClient_SaveZone(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ err := client.SaveZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+}
+
+func TestClient_SaveZone_emptyForwards(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add_empty_forwards.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: slices.Concat([]Record{fakeARecord(), record}),
+ }
+
+ err := client.SaveZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+}
+
+func TestClient_SaveZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord()},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ err := client.SaveZone(context.Background(), "example.com", zone)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key")
+}
+
+func TestClient_ValidateZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /example.com/check",
+ servermock.ResponseFromFixture("zone_add_validate_ok.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ zone, err := client.ValidateZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ Report: &Report{IsValid: true},
+ }
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_ValidateZone_report(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /example.com/check",
+ servermock.ResponseFromFixture("zone_add_validate_ko.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ zone, err := client.ValidateZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ Report: fakeReport(),
+ }
+
+ assert.EqualError(t, zone.Report, `record error (ERROR): "120" is not a valid TTL, URL forward error (ERROR): string, mail forward error (ERROR): string, zone error (ERROR): string`)
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_ValidateZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /example.com/check",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord()},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ _, err := client.ValidateZone(context.Background(), "example.com", zone)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key")
+}
+
+func fakeARecord() Record {
+ return Record{
+ ID: 1000,
+ Type: "A",
+ Host: "@",
+ TTL: 600,
+ RData: "string",
+ Updated: ptr.Pointer(true),
+ Locked: ptr.Pointer(true),
+ IsDynDNS: ptr.Pointer(true),
+ Proxy: "ON",
+ }
+}
+
+func fakeURLForward() URLForward {
+ return URLForward{
+ ID: 2000,
+ ForwardType: "FRAME",
+ Host: "string",
+ URL: "string",
+ Title: "string",
+ Keywords: "string",
+ Description: "string",
+ Updated: ptr.Pointer(true),
+ }
+}
+
+func fakeMailForward() MailForward {
+ return MailForward{
+ ID: 3000,
+ Source: "string",
+ Destination: "string",
+ Updated: ptr.Pointer(true),
+ }
+}
+
+func fakeReport() *Report {
+ return &Report{
+ IsValid: false,
+ RecordErrors: []RecordError{{
+ Messages: []string{`"120" is not a valid TTL`},
+ Severity: "ERROR",
+ Record: fakeARecord(),
+ }},
+ URLForwardErrors: []URLForwardError{{
+ Messages: []string{"string"},
+ Severity: "ERROR",
+ URLForward: fakeURLForward(),
+ }},
+ MailForwardErrors: []MailForwardError{{
+ Messages: []string{"string"},
+ MailForward: fakeMailForward(),
+ Severity: "ERROR",
+ }},
+ ZoneErrors: []ZoneError{{
+ Message: "string",
+ Severity: "ERROR",
+ Records: []Record{fakeARecord()},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }},
+ }
+}
diff --git a/providers/dns/eurodns/internal/fixtures/error.json b/providers/dns/eurodns/internal/fixtures/error.json
new file mode 100644
index 000000000..82a334598
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/error.json
@@ -0,0 +1,8 @@
+{
+ "errors": [
+ {
+ "code": "INVALID_API_KEY",
+ "title": "Invalid API Key"
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add.json b/providers/dns/eurodns/internal/fixtures/zone_add.json
new file mode 100644
index 000000000..db8142357
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add.json
@@ -0,0 +1,46 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json b/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json
new file mode 100644
index 000000000..64f8530c9
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json
@@ -0,0 +1,28 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [],
+ "mailForwards": []
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json
new file mode 100644
index 000000000..e07d42299
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json
@@ -0,0 +1,139 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ],
+ "report": {
+ "isValid": false,
+ "recordErrors": [
+ {
+ "messages": [
+ "\"120\" is not a valid TTL"
+ ],
+ "record": {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ "severity": "ERROR"
+ }
+ ],
+ "urlForwardErrors": [
+ {
+ "messages": [
+ "string"
+ ],
+ "urlForward": {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ },
+ "severity": "ERROR"
+ }
+ ],
+ "mailForwardErrors": [
+ {
+ "messages": [
+ "string"
+ ],
+ "mailForward": {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ },
+ "severity": "ERROR"
+ }
+ ],
+ "zoneErrors": [
+ {
+ "message": "string",
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ],
+ "severity": "ERROR"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json
new file mode 100644
index 000000000..ba0ddfefb
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json
@@ -0,0 +1,49 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ],
+ "report": {
+ "isValid": true
+ }
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_get.json b/providers/dns/eurodns/internal/fixtures/zone_get.json
new file mode 100644
index 000000000..ebbc8593e
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_get.json
@@ -0,0 +1,37 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_remove.json b/providers/dns/eurodns/internal/fixtures/zone_remove.json
new file mode 100644
index 000000000..ebbc8593e
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_remove.json
@@ -0,0 +1,37 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/types.go b/providers/dns/eurodns/internal/types.go
new file mode 100644
index 000000000..891b02e14
--- /dev/null
+++ b/providers/dns/eurodns/internal/types.go
@@ -0,0 +1,136 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ Errors []Error `json:"errors"`
+}
+
+func (a *APIError) Error() string {
+ var msg []string
+
+ for _, e := range a.Errors {
+ msg = append(msg, fmt.Sprintf("%s: %s", e.Code, e.Title))
+ }
+
+ return strings.Join(msg, ", ")
+}
+
+type Error struct {
+ Code string `json:"code"`
+ Title string `json:"title"`
+}
+
+type Zone struct {
+ Name string `json:"name,omitempty"`
+ DomainConnect bool `json:"domainConnect,omitempty"`
+ Records []Record `json:"records"`
+ URLForwards []URLForward `json:"urlForwards"`
+ MailForwards []MailForward `json:"mailForwards"`
+ Report *Report `json:"report,omitempty"`
+}
+
+type Record struct {
+ ID int `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Host string `json:"host,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ RData string `json:"rdata,omitempty"`
+ Updated *bool `json:"updated"`
+ Locked *bool `json:"locked"`
+ IsDynDNS *bool `json:"isDynDns"`
+ Proxy string `json:"proxy,omitempty"`
+}
+
+type URLForward struct {
+ ID int `json:"id,omitempty"`
+ ForwardType string `json:"forwardType,omitempty"`
+ Host string `json:"host,omitempty"`
+ URL string `json:"url,omitempty"`
+ Title string `json:"title,omitempty"`
+ Keywords string `json:"keywords,omitempty"`
+ Description string `json:"description,omitempty"`
+ Updated *bool `json:"updated,omitempty"`
+}
+
+type MailForward struct {
+ ID int `json:"id,omitempty"`
+ Source string `json:"source,omitempty"`
+ Destination string `json:"destination,omitempty"`
+ Updated *bool `json:"updated,omitempty"`
+}
+
+type Report struct {
+ IsValid bool `json:"isValid,omitempty"`
+ RecordErrors []RecordError `json:"recordErrors,omitempty"`
+ URLForwardErrors []URLForwardError `json:"urlForwardErrors,omitempty"`
+ MailForwardErrors []MailForwardError `json:"mailForwardErrors,omitempty"`
+ ZoneErrors []ZoneError `json:"zoneErrors,omitempty"`
+}
+
+func (r *Report) Error() string {
+ var msg []string
+
+ for _, e := range r.RecordErrors {
+ msg = append(msg, e.Error())
+ }
+
+ for _, e := range r.URLForwardErrors {
+ msg = append(msg, e.Error())
+ }
+
+ for _, e := range r.MailForwardErrors {
+ msg = append(msg, e.Error())
+ }
+
+ for _, e := range r.ZoneErrors {
+ msg = append(msg, e.Error())
+ }
+
+ return strings.Join(msg, ", ")
+}
+
+type RecordError struct {
+ Messages []string `json:"messages,omitempty"`
+ Record Record `json:"record"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *RecordError) Error() string {
+ return fmt.Sprintf("record error (%s): %s", e.Severity, strings.Join(e.Messages, ", "))
+}
+
+type URLForwardError struct {
+ Messages []string `json:"messages,omitempty"`
+ URLForward URLForward `json:"urlForward"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *URLForwardError) Error() string {
+ return fmt.Sprintf("URL forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", "))
+}
+
+type MailForwardError struct {
+ Messages []string `json:"messages,omitempty"`
+ MailForward MailForward `json:"mailForward"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *MailForwardError) Error() string {
+ return fmt.Sprintf("mail forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", "))
+}
+
+type ZoneError struct {
+ Message string `json:"message,omitempty"`
+ Records []Record `json:"records,omitempty"`
+ URLForwards []URLForward `json:"urlForwards,omitempty"`
+ MailForwards []MailForward `json:"mailForwards,omitempty"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *ZoneError) Error() string {
+ return fmt.Sprintf("zone error (%s): %s", e.Severity, e.Message)
+}
diff --git a/providers/dns/excedo/excedo.go b/providers/dns/excedo/excedo.go
new file mode 100644
index 000000000..ae9128b94
--- /dev/null
+++ b/providers/dns/excedo/excedo.go
@@ -0,0 +1,176 @@
+// Package excedo implements a DNS provider for solving the DNS-01 challenge using Excedo.
+package excedo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "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/excedo/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EXCEDO_"
+
+ EnvAPIURL = envNamespace + "API_URL"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIURL string
+ APIKey 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, 60),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
+ 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
+
+ recordsMu sync.Mutex
+ records map[string]int64
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Excedo.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIURL, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("excedo: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIURL = values[EnvAPIURL]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Excedo.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("excedo: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIURL, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("excedo: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ records: make(map[string]int64),
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("excedo: %w", err)
+ }
+
+ record := internal.Record{
+ DomainName: dns01.UnFqdn(authZone),
+ Name: subDomain,
+ Type: "TXT",
+ Content: info.Value,
+ TTL: strconv.Itoa(d.config.TTL),
+ }
+
+ recordID, err := d.client.AddRecord(ctx, record)
+ if err != nil {
+ return fmt.Errorf("excedo: add record: %w", err)
+ }
+
+ d.recordsMu.Lock()
+ d.records[token] = recordID
+ d.recordsMu.Unlock()
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err)
+ }
+
+ d.recordsMu.Lock()
+ recordID, ok := d.records[token]
+ d.recordsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("excedo: unknown record ID for '%s'", info.EffectiveFQDN)
+ }
+
+ err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), strconv.FormatInt(recordID, 10))
+ if err != nil {
+ return fmt.Errorf("excedo: delete record: %w", err)
+ }
+
+ d.recordsMu.Lock()
+ delete(d.records, token)
+ d.recordsMu.Unlock()
+
+ 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/excedo/excedo.toml b/providers/dns/excedo/excedo.toml
new file mode 100644
index 000000000..9f9874c62
--- /dev/null
+++ b/providers/dns/excedo/excedo.toml
@@ -0,0 +1,24 @@
+Name = "Excedo"
+Description = ''''''
+URL = "https://excedo.se/"
+Code = "excedo"
+Since = "v4.33.0"
+
+Example = '''
+EXCEDO_API_KEY=your-api-key \
+EXCEDO_API_URL=your-base-url \
+lego --dns excedo -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EXCEDO_API_KEY = "API key"
+ EXCEDO_API_URL = "API base URL"
+ [Configuration.Additional]
+ EXCEDO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ EXCEDO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ EXCEDO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ EXCEDO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "none"
diff --git a/providers/dns/excedo/excedo_test.go b/providers/dns/excedo/excedo_test.go
new file mode 100644
index 000000000..f2350c035
--- /dev/null
+++ b/providers/dns/excedo/excedo_test.go
@@ -0,0 +1,210 @@
+package excedo
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIURL, EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIURL: "https://example.com",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing the API key",
+ envVars: map[string]string{
+ EnvAPIURL: "https://example.com",
+ EnvAPIKey: "",
+ },
+ expected: "excedo: some credentials information are missing: EXCEDO_API_KEY",
+ },
+ {
+ desc: "missing the API URL",
+ envVars: map[string]string{
+ EnvAPIURL: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "excedo: some credentials information are missing: EXCEDO_API_URL",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "excedo: some credentials information are missing: EXCEDO_API_URL,EXCEDO_API_KEY",
+ },
+ }
+
+ 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
+ apiURL string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiURL: "https://example.com",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing the API key",
+ apiURL: "https://example.com",
+ expected: "excedo: credentials missing",
+ },
+ {
+ desc: "missing the API URL",
+ apiKey: "secret",
+ expected: "excedo: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "excedo: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIURL = test.apiURL
+ config.APIKey = test.apiKey
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIURL = server.URL
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromInternal("login.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret"),
+ ).
+ Route("POST /dns/addrecord/",
+ servermock.ResponseFromInternal("addrecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ servermock.CheckForm().Strict().
+ With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("domainName", "example.com").
+ With("name", "_acme-challenge").
+ With("ttl", "60").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromInternal("login.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret"),
+ ).
+ Route("POST /dns/deleterecord/",
+ servermock.ResponseFromInternal("deleterecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ ).
+ Build(t)
+
+ provider.records["abc"] = 19695822
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/excedo/internal/client.go b/providers/dns/excedo/internal/client.go
new file mode 100644
index 000000000..a5d8be88b
--- /dev/null
+++ b/providers/dns/excedo/internal/client.go
@@ -0,0 +1,205 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ querystring "github.com/google/go-querystring/query"
+)
+
+type responseChecker interface {
+ Check() error
+}
+
+// Client the Excedo API client.
+type Client struct {
+ apiKey string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+
+ token *ExpirableToken
+ muToken sync.Mutex
+}
+
+// NewClient creates a new Client.
+func NewClient(apiURL, apiKey string) (*Client, error) {
+ if apiURL == "" || apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, err := url.Parse(apiURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ apiKey: apiKey,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, record Record) (int64, error) {
+ payload, err := querystring.Values(record)
+ if err != nil {
+ return 0, err
+ }
+
+ endpoint := c.baseURL.JoinPath("/dns/addrecord/")
+
+ req, err := newFormRequest(ctx, http.MethodPost, endpoint, payload)
+ if err != nil {
+ return 0, err
+ }
+
+ result := new(AddRecordResponse)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return 0, err
+ }
+
+ return result.RecordID, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error {
+ endpoint := c.baseURL.JoinPath("/dns/deleterecord/")
+
+ data := map[string]string{
+ "domainname": dns01.UnFqdn(zone),
+ "recordid": recordID,
+ }
+
+ req, err := newMultipartRequest(ctx, http.MethodPost, endpoint, data)
+ if err != nil {
+ return err
+ }
+
+ result := new(BaseResponse)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) GetRecords(ctx context.Context, zone string) (map[string]Zone, error) {
+ endpoint := c.baseURL.JoinPath("/dns/getrecords/")
+
+ query := endpoint.Query()
+ query.Set("domainname", zone)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(GetRecordsResponse)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.DNS, nil
+}
+
+func (c *Client) do(req *http.Request, result responseChecker) error {
+ useragent.SetHeader(req.Header)
+
+ 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)
+ }
+
+ 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 result.Check()
+}
+
+func newMultipartRequest(ctx context.Context, method string, endpoint *url.URL, data map[string]string) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ writer := multipart.NewWriter(buf)
+
+ for k, v := range data {
+ err := writer.WriteField(k, v)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ err := writer.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ body := bytes.NewReader(buf.Bytes())
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ return req, nil
+}
+
+func newFormRequest(ctx context.Context, method string, endpoint *url.URL, form url.Values) (*http.Request, error) {
+ var body io.Reader
+
+ if len(form) > 0 {
+ body = bytes.NewReader([]byte(form.Encode()))
+ } else {
+ body = http.NoBody
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ if method == http.MethodPost {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+
+ return req, nil
+}
diff --git a/providers/dns/excedo/internal/client_test.go b/providers/dns/excedo/internal/client_test.go
new file mode 100644
index 000000000..f4fd52c00
--- /dev/null
+++ b/providers/dns/excedo/internal/client_test.go
@@ -0,0 +1,137 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ )
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/addrecord/",
+ servermock.ResponseFromFixture("addrecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ servermock.CheckForm().Strict().
+ With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("domainName", "example.com").
+ With("name", "_acme-challenge").
+ With("ttl", "60").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ record := Record{
+ DomainName: "example.com",
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: "60",
+ }
+
+ recordID, err := client.AddRecord(t.Context(), record)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, 19695822, recordID)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/addrecord/",
+ servermock.ResponseFromFixture("error.json"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ record := Record{
+ DomainName: "example.com",
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: "60",
+ }
+
+ _, err := client.AddRecord(t.Context(), record)
+ require.EqualError(t, err, "2003: Required parameter missing")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/deleterecord/",
+ servermock.ResponseFromFixture("deleterecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ err := client.DeleteRecord(t.Context(), "example.com", "19695822")
+ require.NoError(t, err)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/getrecords/",
+ servermock.ResponseFromFixture("getrecords.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ servermock.CheckQueryParameter().Strict().
+ With("domainname", "example.com"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ zones, err := client.GetRecords(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := map[string]Zone{
+ "example.com": {
+ DNSType: "type",
+ Records: []Record{{
+ RecordID: "1234",
+ Name: "_acme-challenge.example.com",
+ Type: "TXT",
+ Content: "txt-value",
+ TTL: "60",
+ }},
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
diff --git a/providers/dns/excedo/internal/fixtures/addrecord.json b/providers/dns/excedo/internal/fixtures/addrecord.json
new file mode 100644
index 000000000..f1f7bf958
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/addrecord.json
@@ -0,0 +1,15 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "recordid": 19695822,
+ "session": {
+ "accID": "1234",
+ "usrID": "1234",
+ "status": "active",
+ "expire": {
+ "date": "2026-03-10 19:03:18",
+ "seconds": 5678
+ }
+ },
+ "runtime": 0.2852
+}
diff --git a/providers/dns/excedo/internal/fixtures/deleterecord.json b/providers/dns/excedo/internal/fixtures/deleterecord.json
new file mode 100644
index 000000000..5c2431b1c
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/deleterecord.json
@@ -0,0 +1,14 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "session": {
+ "accID": "1234",
+ "usrID": "1234",
+ "status": "active",
+ "expire": {
+ "date": "2026-03-10 19:03:18",
+ "seconds": 5678
+ }
+ },
+ "runtime": 0.2852
+}
diff --git a/providers/dns/excedo/internal/fixtures/error.json b/providers/dns/excedo/internal/fixtures/error.json
new file mode 100644
index 000000000..5a24ec247
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/error.json
@@ -0,0 +1,18 @@
+{
+ "code": 2003,
+ "desc": "Required parameter missing",
+ "missing": [
+ "domainname",
+ "recordid"
+ ],
+ "session": {
+ "accID": "1234",
+ "usrID": "1234",
+ "status": "active",
+ "expire": {
+ "date": "2026-03-10 19:03:18",
+ "seconds": 5485
+ }
+ },
+ "runtime": 0.0534
+}
diff --git a/providers/dns/excedo/internal/fixtures/getrecords.json b/providers/dns/excedo/internal/fixtures/getrecords.json
new file mode 100644
index 000000000..215a8abb2
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/getrecords.json
@@ -0,0 +1,23 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "dns": {
+ "example.com": {
+ "dnstype": "type",
+ "recordusage": {
+ "used": 74
+ },
+ "records": [
+ {
+ "recordid": "1234",
+ "name": "_acme-challenge.example.com",
+ "type": "TXT",
+ "content": "txt-value",
+ "ttl": "60",
+ "prio": null,
+ "change_date": null
+ }
+ ]
+ }
+ }
+}
diff --git a/providers/dns/excedo/internal/fixtures/login.json b/providers/dns/excedo/internal/fixtures/login.json
new file mode 100644
index 000000000..2defb9843
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/login.json
@@ -0,0 +1,7 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "parameters": {
+ "token": "session-token"
+ }
+}
diff --git a/providers/dns/excedo/internal/identity.go b/providers/dns/excedo/internal/identity.go
new file mode 100644
index 000000000..5c9ca119d
--- /dev/null
+++ b/providers/dns/excedo/internal/identity.go
@@ -0,0 +1,75 @@
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+type ExpirableToken struct {
+ Token string
+ Expires time.Time
+}
+
+func (t *ExpirableToken) IsExpired() bool {
+ return time.Now().After(t.Expires)
+}
+
+func (c *Client) Login(ctx context.Context) (string, error) {
+ endpoint := c.baseURL.JoinPath("/authenticate/login/")
+
+ req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ result := new(LoginResponse)
+
+ err = c.do(req, result)
+ if err != nil {
+ return "", err
+ }
+
+ if result.Code != 1000 && result.Code != 1300 {
+ return "", fmt.Errorf("%d: %s", result.Code, result.Description)
+ }
+
+ return result.Parameters.Token, nil
+}
+
+func (c *Client) authenticate(ctx context.Context) (string, error) {
+ c.muToken.Lock()
+ defer c.muToken.Unlock()
+
+ if c.token == nil || c.token.IsExpired() {
+ token, err := c.Login(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ c.token = &ExpirableToken{
+ Token: token,
+ Expires: time.Now().Add(2*time.Hour - time.Minute),
+ }
+
+ return token, nil
+ }
+
+ return c.token.Token, nil
+}
+
+func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result responseChecker) error {
+ token, err := c.authenticate(ctx)
+ if err != nil {
+ return err
+ }
+
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ return c.do(req, result)
+}
diff --git a/providers/dns/excedo/internal/identity_test.go b/providers/dns/excedo/internal/identity_test.go
new file mode 100644
index 000000000..86b7eb9d8
--- /dev/null
+++ b/providers/dns/excedo/internal/identity_test.go
@@ -0,0 +1,35 @@
+package internal
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClient_Login(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret"),
+ ).
+ Build(t)
+
+ token, err := client.Login(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, "session-token", token)
+}
+
+func TestClient_Login_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromFixture("error.json"),
+ ).
+ Build(t)
+
+ _, err := client.Login(t.Context())
+ require.EqualError(t, err, "2003: Required parameter missing")
+}
diff --git a/providers/dns/excedo/internal/types.go b/providers/dns/excedo/internal/types.go
new file mode 100644
index 000000000..eb6ce8462
--- /dev/null
+++ b/providers/dns/excedo/internal/types.go
@@ -0,0 +1,65 @@
+package internal
+
+import "fmt"
+
+type BaseResponse struct {
+ Code int `json:"code"`
+ Description string `json:"desc"`
+}
+
+func (r BaseResponse) Check() error {
+ // Response codes:
+ // - 1000: Command completed successfully
+ // - 1300: Command completed successfully; no messages
+ // - 2001: Command syntax error
+ // - 2002: Command use error
+ // - 2003: Required parameter missing
+ // - 2004: Parameter value range error
+ // - 2104: Billing failure
+ // - 2200: Authentication error
+ // - 2201: Authorization error
+ // - 2303: Object does not exist
+ // - 2304: Object status prohibits operation
+ // - 2309: Object duplicate found
+ // - 2400: Command failed
+ // - 2500: Command failed; server closing connection
+ if r.Code != 1000 && r.Code != 1300 {
+ return fmt.Errorf("%d: %s", r.Code, r.Description)
+ }
+
+ return nil
+}
+
+type GetRecordsResponse struct {
+ BaseResponse
+
+ DNS map[string]Zone `json:"dns"`
+}
+
+type Zone struct {
+ DNSType string `json:"dnstype"`
+ Records []Record `json:"records"`
+}
+
+type Record struct {
+ DomainName string `json:"domainName,omitempty" url:"domainName,omitempty"`
+ RecordID string `json:"recordid,omitempty" url:"recordid,omitempty"`
+ Name string `json:"name,omitempty" url:"name,omitempty"`
+ Type string `json:"type,omitempty" url:"type,omitempty"`
+ Content string `json:"content,omitempty" url:"content,omitempty"`
+ TTL string `json:"ttl,omitempty" url:"ttl,omitempty"`
+}
+
+type AddRecordResponse struct {
+ BaseResponse
+
+ RecordID int64 `json:"recordid"`
+}
+
+type LoginResponse struct {
+ BaseResponse
+
+ Parameters struct {
+ Token string `json:"token"`
+ } `json:"parameters"`
+}
diff --git a/providers/dns/gigahostno/internal/client_test.go b/providers/dns/gigahostno/internal/client_test.go
index aac65bceb..8d1298947 100644
--- a/providers/dns/gigahostno/internal/client_test.go
+++ b/providers/dns/gigahostno/internal/client_test.go
@@ -38,55 +38,25 @@ func TestClient_GetZones(t *testing.T) {
expected := []Zone{
{
- ID: "123",
- Name: "example.com",
- NameDisplay: "example.com",
- Type: "NATIVE",
- Active: "1",
- Protected: "1",
- IsRegistered: "1",
- Updated: false,
- CustomerID: "16030",
- DomainRegistrar: "norid",
- DomainStatus: "active",
- DomainExpiryDate: "2026-11-23 15:17:38",
- DomainAutoRenew: "1",
- ExternalDNS: "0",
- RecordCount: 4,
+ ID: "123",
+ Name: "example.com",
+ NameDisplay: "example.com",
+ Type: "NATIVE",
+ Active: "1",
},
{
- ID: "226",
- Name: "example.org",
- NameDisplay: "example.org",
- Type: "NATIVE",
- Active: "1",
- Protected: "1",
- IsRegistered: "1",
- Updated: false,
- CustomerID: "16030",
- DomainRegistrar: "norid",
- DomainStatus: "active",
- DomainExpiryDate: "2026-11-23 14:15:01",
- DomainAutoRenew: "1",
- ExternalDNS: "0",
- RecordCount: 5,
+ ID: "226",
+ Name: "example.org",
+ NameDisplay: "example.org",
+ Type: "NATIVE",
+ Active: "1",
},
{
- ID: "229",
- Name: "example.xn--zckzah",
- NameDisplay: "example.テスト",
- Type: "NATIVE",
- Active: "1",
- Protected: "1",
- IsRegistered: "1",
- Updated: false,
- CustomerID: "16030",
- DomainRegistrar: "norid",
- DomainStatus: "active",
- DomainExpiryDate: "2026-12-01 12:40:48",
- DomainAutoRenew: "1",
- ExternalDNS: "0",
- RecordCount: 4,
+ ID: "229",
+ Name: "example.xn--zckzah",
+ NameDisplay: "example.テスト",
+ Type: "NATIVE",
+ Active: "1",
},
}
diff --git a/providers/dns/gigahostno/internal/fixtures/zones.json b/providers/dns/gigahostno/internal/fixtures/zones.json
index f4d927335..d45b0ac49 100644
--- a/providers/dns/gigahostno/internal/fixtures/zones.json
+++ b/providers/dns/gigahostno/internal/fixtures/zones.json
@@ -30,7 +30,7 @@
"domain_dnssec_data": null,
"domain_protected_email": null,
"zone_created": "2025-11-23 16:17:29",
- "zone_updated": false,
+ "zone_updated": 1700000000,
"external_dns": "0",
"record_count": 4,
"zone_name_display": "example.com"
@@ -59,7 +59,7 @@
"domain_dnssec_data": null,
"domain_protected_email": null,
"zone_created": "2025-11-23 15:13:27",
- "zone_updated": false,
+ "zone_updated": 1700000000,
"external_dns": "0",
"record_count": 5,
"zone_name_display": "example.org"
@@ -88,7 +88,7 @@
"domain_dnssec_data": null,
"domain_protected_email": null,
"zone_created": "2025-11-23 16:37:15",
- "zone_updated": false,
+ "zone_updated": 1700000000,
"external_dns": "0",
"record_count": 4,
"zone_name_display": "example.\u30C6\u30B9\u30C8"
diff --git a/providers/dns/gigahostno/internal/types.go b/providers/dns/gigahostno/internal/types.go
index cbb7b8b23..e998dc084 100644
--- a/providers/dns/gigahostno/internal/types.go
+++ b/providers/dns/gigahostno/internal/types.go
@@ -26,21 +26,11 @@ type APIResponse[T any] struct {
}
type Zone struct {
- ID string `json:"zone_id,omitempty"`
- Name string `json:"zone_name,omitempty"`
- NameDisplay string `json:"zone_name_display,omitempty"`
- Type string `json:"zone_type,omitempty"`
- Active string `json:"zone_active,omitempty"`
- Protected string `json:"zone_protected,omitempty"`
- IsRegistered string `json:"zone_is_registered,omitempty"`
- Updated bool `json:"zone_updated,omitempty"`
- CustomerID string `json:"cust_id,omitempty"`
- DomainRegistrar string `json:"domain_registrar,omitempty"`
- DomainStatus string `json:"domain_status,omitempty"`
- DomainExpiryDate string `json:"domain_expiry_date,omitempty"`
- DomainAutoRenew string `json:"domain_auto_renew,omitempty"`
- ExternalDNS string `json:"external_dns,omitempty"`
- RecordCount int `json:"record_count,omitempty"`
+ ID string `json:"zone_id,omitempty"`
+ Name string `json:"zone_name,omitempty"`
+ NameDisplay string `json:"zone_name_display,omitempty"`
+ Type string `json:"zone_type,omitempty"`
+ Active string `json:"zone_active,omitempty"`
}
type Record struct {
diff --git a/providers/dns/internal/useragent/useragent.go b/providers/dns/internal/useragent/useragent.go
index 43e77b23d..090c9109a 100644
--- a/providers/dns/internal/useragent/useragent.go
+++ b/providers/dns/internal/useragent/useragent.go
@@ -15,7 +15,7 @@ const (
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
- ourUserAgentComment = "release"
+ ourUserAgentComment = "detach"
)
// Get builds and returns the User-Agent string.
diff --git a/providers/dns/liara/internal/client.go b/providers/dns/liara/internal/client.go
index 93cdcf7c8..95c39695b 100644
--- a/providers/dns/liara/internal/client.go
+++ b/providers/dns/liara/internal/client.go
@@ -20,17 +20,23 @@ const defaultBaseURL = "https://dns-service.iran.liara.ir"
type Client struct {
baseURL *url.URL
httpClient *http.Client
+
+ teamID string
}
// NewClient creates a new Client.
-func NewClient(hc *http.Client) *Client {
+func NewClient(hc *http.Client, teamID string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
- return &Client{httpClient: hc, baseURL: baseURL}
+ return &Client{
+ httpClient: hc,
+ baseURL: baseURL,
+ teamID: teamID,
+ }
}
// GetRecords gets the records of a domain.
@@ -38,7 +44,7 @@ func NewClient(hc *http.Client) *Client {
func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -73,7 +79,7 @@ func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, e
func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
- req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -108,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domainName string, record Rec
func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -143,7 +149,7 @@ func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*R
func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
- req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
@@ -162,7 +168,14 @@ func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string)
return nil
}
-func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ if c.teamID != "" {
+ query := endpoint.Query()
+ query.Set("teamID", c.teamID)
+
+ endpoint.RawQuery = query.Encode()
+ }
+
buf := new(bytes.Buffer)
if payload != nil {
diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go
index 57ac7e8b3..b6d007046 100644
--- a/providers/dns/liara/internal/client_test.go
+++ b/providers/dns/liara/internal/client_test.go
@@ -13,10 +13,10 @@ import (
const apiKey = "key"
-func mockBuilder() *servermock.Builder[*Client] {
+func mockBuilder(teamID string) *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
- client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey))
+ client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID)
client.baseURL, _ = url.Parse(server.URL)
return client, nil
@@ -26,7 +26,7 @@ func mockBuilder() *servermock.Builder[*Client] {
}
func TestClient_GetRecords(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")).
Build(t)
@@ -50,7 +50,7 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")).
Build(t)
@@ -72,7 +72,7 @@ func TestClient_GetRecord(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("POST /api/v1/zones/example.com/dns-records",
servermock.ResponseFromFixture("RecordResponse.json").
WithStatusCode(http.StatusCreated),
@@ -108,8 +108,47 @@ func TestClient_CreateRecord(t *testing.T) {
assert.Equal(t, expected, record)
}
+func TestClient_CreateRecord_withTeamID(t *testing.T) {
+ client := mockBuilder("123").
+ Route("POST /api/v1/zones/example.com/dns-records",
+ servermock.ResponseFromFixture("RecordResponse.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`),
+ servermock.CheckQueryParameter().Strict().With("teamID", "123"),
+ ).
+ Build(t)
+
+ data := Record{
+ Type: "string",
+ Name: "string",
+ Contents: []Content{
+ {
+ Text: "string",
+ },
+ },
+ TTL: 3600,
+ }
+
+ record, err := client.CreateRecord(t.Context(), "example.com", data)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "string",
+ Type: "string",
+ Name: "string",
+ Contents: []Content{
+ {
+ Text: "string",
+ },
+ },
+ TTL: 3600,
+ }
+
+ assert.Equal(t, expected, record)
+}
+
func TestClient_DeleteRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.Noop().
WithStatusCode(http.StatusNoContent)).
@@ -120,7 +159,7 @@ func TestClient_DeleteRecord(t *testing.T) {
}
func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.Noop().
WithStatusCode(http.StatusNotFound)).
@@ -131,7 +170,7 @@ func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.ResponseFromFixture("error.json").
WithStatusCode(http.StatusUnauthorized)).
diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go
index b91b004cc..c7e403eed 100644
--- a/providers/dns/liara/liara.go
+++ b/providers/dns/liara/liara.go
@@ -23,6 +23,7 @@ const (
envNamespace = "LIARA_"
EnvAPIKey = envNamespace + "API_KEY"
+ EnvTeamID = envNamespace + "TEAM_ID"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -39,7 +40,9 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- APIKey string
+ APIKey string
+ TeamID string
+
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
@@ -77,6 +80,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.APIKey = values[EnvAPIKey]
+ config.TeamID = env.GetOrFile(EnvTeamID)
return NewDNSProviderConfig(config)
}
@@ -112,6 +116,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
clientdebug.Wrap(
internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey),
),
+ config.TeamID,
)
return &DNSProvider{
diff --git a/providers/dns/liara/liara.toml b/providers/dns/liara/liara.toml
index 4ed53ec75..f471de04e 100644
--- a/providers/dns/liara/liara.toml
+++ b/providers/dns/liara/liara.toml
@@ -13,6 +13,7 @@ lego --dns liara -d '*.example.com' -d example.com run
[Configuration.Credentials]
LIARA_API_KEY = "The API key"
[Configuration.Additional]
+ LIARA_TEAM_ID = "The team ID to access services in a team"
LIARA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
LIARA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
diff --git a/providers/dns/safedns/internal/client.go b/providers/dns/safedns/internal/client.go
index 51b12e99d..628618032 100644
--- a/providers/dns/safedns/internal/client.go
+++ b/providers/dns/safedns/internal/client.go
@@ -19,7 +19,7 @@ const defaultBaseURL = "https://api.ukfast.io/safedns/v1"
const authorizationHeader = "Authorization"
-// Client the UKFast SafeDNS client.
+// Client the ANS SafeDNS client.
type Client struct {
authToken string
diff --git a/providers/dns/safedns/safedns.go b/providers/dns/safedns/safedns.go
index be8ca4fe6..154cfc5ee 100644
--- a/providers/dns/safedns/safedns.go
+++ b/providers/dns/safedns/safedns.go
@@ -1,4 +1,4 @@
-// Package safedns implements a DNS provider for solving the DNS-01 challenge using UKFast SafeDNS.
+// Package safedns implements a DNS provider for solving the DNS-01 challenge using ANS SafeDNS.
package safedns
import (
@@ -75,7 +75,7 @@ func NewDNSProvider() (*DNSProvider, error) {
return NewDNSProviderConfig(config)
}
-// NewDNSProviderConfig return a DNSProvider instance configured for UKFast SafeDNS.
+// NewDNSProviderConfig return a DNSProvider instance configured for ANS SafeDNS.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("safedns: supplied configuration was nil")
diff --git a/providers/dns/safedns/safedns.toml b/providers/dns/safedns/safedns.toml
index 188db66a4..f387f2535 100644
--- a/providers/dns/safedns/safedns.toml
+++ b/providers/dns/safedns/safedns.toml
@@ -1,6 +1,6 @@
-Name = "UKFast SafeDNS"
+Name = "ANS SafeDNS"
Description = ''''''
-URL = "https://www.ukfast.co.uk/dns-hosting.html"
+URL = "https://www.ans.co.uk/"
Code = "safedns"
Since = "v4.6.0"
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index e1b2cc989..9c4bc9e61 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -43,6 +43,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/constellix"
"github.com/go-acme/lego/v4/providers/dns/corenetworks"
"github.com/go-acme/lego/v4/providers/dns/cpanel"
+ "github.com/go-acme/lego/v4/providers/dns/czechia"
"github.com/go-acme/lego/v4/providers/dns/ddnss"
"github.com/go-acme/lego/v4/providers/dns/derak"
"github.com/go-acme/lego/v4/providers/dns/desec"
@@ -67,6 +68,8 @@ import (
"github.com/go-acme/lego/v4/providers/dns/edgeone"
"github.com/go-acme/lego/v4/providers/dns/efficientip"
"github.com/go-acme/lego/v4/providers/dns/epik"
+ "github.com/go-acme/lego/v4/providers/dns/eurodns"
+ "github.com/go-acme/lego/v4/providers/dns/excedo"
"github.com/go-acme/lego/v4/providers/dns/exec"
"github.com/go-acme/lego/v4/providers/dns/exoscale"
"github.com/go-acme/lego/v4/providers/dns/f5xc"
@@ -273,6 +276,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return corenetworks.NewDNSProvider()
case "cpanel":
return cpanel.NewDNSProvider()
+ case "czechia":
+ return czechia.NewDNSProvider()
case "ddnss":
return ddnss.NewDNSProvider()
case "derak":
@@ -321,6 +326,10 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return efficientip.NewDNSProvider()
case "epik":
return epik.NewDNSProvider()
+ case "eurodns":
+ return eurodns.NewDNSProvider()
+ case "excedo":
+ return excedo.NewDNSProvider()
case "exec":
return exec.NewDNSProvider()
case "exoscale":