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":