diff --git a/README.md b/README.md
index 6324ece67..800aad958 100644
--- a/README.md
+++ b/README.md
@@ -136,165 +136,165 @@ If your DNS provider is not supported, please open an [issue](https://github.com
Efficient IP |
Epik |
+ | EUserv |
Exoscale |
External program |
F5 XC |
- freemyip.com |
+ | freemyip.com |
G-Core |
Gandi |
Gandi Live DNS (v5) |
- Gigahost.no |
+ | Gigahost.no |
Glesys |
Go Daddy |
Google Cloud |
- Google Domains |
+ | Google Domains |
Gravity |
Hetzner |
Hosting.de |
- Hosting.nl |
+ | Hosting.nl |
Hostinger |
Hosttech |
HTTP request |
- http.net |
+ | http.net |
Huawei Cloud |
Hurricane Electric DNS |
HyperOne |
- IBM Cloud (SoftLayer) |
+ | IBM Cloud (SoftLayer) |
IIJ DNS Platform Service |
Infoblox |
Infomaniak |
- Internet Initiative Japan |
+ | Internet Initiative Japan |
Internet.bs |
INWX |
Ionos |
- Ionos Cloud |
+ | Ionos Cloud |
IPv64 |
ISPConfig 3 |
ISPConfig 3 - Dynamic DNS (DDNS) Module |
- iwantmyname (Deprecated) |
+ | iwantmyname (Deprecated) |
JD Cloud |
Joker |
Joohoi's ACME-DNS |
- KeyHelp |
+ | KeyHelp |
Liara |
Lima-City |
Linode (v4) |
- Liquid Web |
+ | Liquid Web |
Loopia |
LuaDNS |
Mail-in-a-Box |
- ManageEngine CloudDNS |
+ | ManageEngine CloudDNS |
Manual |
Metaname |
Metaregistrar |
- mijn.host |
+ | mijn.host |
Mittwald |
myaddr.{tools,dev,io} |
MyDNS.jp |
- MythicBeasts |
+ | MythicBeasts |
Name.com |
Namecheap |
Namesilo |
- NearlyFreeSpeech.NET |
+ | NearlyFreeSpeech.NET |
Neodigit |
Netcup |
Netlify |
- Nicmanager |
+ | Nicmanager |
NIFCloud |
Njalla |
Nodion |
- NS1 |
+ | NS1 |
Octenium |
Open Telekom Cloud |
Oracle Cloud |
- OVH |
+ | OVH |
plesk.com |
Porkbun |
PowerDNS |
- Rackspace |
+ | Rackspace |
Rain Yun/雨云 |
RcodeZero |
reg.ru |
- Regfish |
+ | Regfish |
RFC2136 |
RimuHosting |
RU CENTER |
- Sakura Cloud |
+ | Sakura Cloud |
Scaleway |
Selectel |
Selectel v2 |
- SelfHost.(de|eu) |
+ | SelfHost.(de|eu) |
Servercow |
Shellrent |
Simply.com |
- Sonic |
+ | Sonic |
Spaceship |
Stackpath |
Syse |
- Technitium |
+ | Technitium |
Tencent Cloud DNS |
Tencent EdgeOne |
Timeweb Cloud |
- TodayNIC/时代互联 |
+ | TodayNIC/时代互联 |
TransIP |
UKFast SafeDNS |
Ultradns |
- United-Domains |
+ | United-Domains |
Variomedia |
VegaDNS |
Vercel |
- Versio.[nl|eu|uk] |
+ | Versio.[nl|eu|uk] |
VinylDNS |
Virtualname |
VK Cloud |
- Volcano Engine/火山引擎 |
+ | Volcano Engine/火山引擎 |
Vscale |
Vultr |
webnames.ca |
- webnames.ru |
+ | webnames.ru |
Websupport |
WEDOS |
West.cn/西部数码 |
- Yandex 360 |
+ | Yandex 360 |
Yandex Cloud |
Yandex PDD |
Zone.ee |
- ZoneEdit |
+ | ZoneEdit |
Zonomi |
|
|
- |
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 357834a3c..f4ea780d3 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -72,6 +72,7 @@ func allDNSCodes() string {
"edgeone",
"efficientip",
"epik",
+ "euserv",
"exec",
"exoscale",
"f5xc",
@@ -1517,6 +1518,28 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/epik`)
+ case "euserv":
+ // generated from: providers/dns/euserv/euserv.toml
+ ew.writeln(`Configuration for EUserv.`)
+ ew.writeln(`Code: 'euserv'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EUSERV_EMAIL": The customer email address. You can also use the customer id instead.`)
+ ew.writeln(` - "EUSERV_ORDER_ID": The order ID of the API contract that you want to use for this login session.`)
+ ew.writeln(` - "EUSERV_PASSWORD": The customer account password.`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EUSERV_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EUSERV_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EUSERV_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "EUSERV_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/euserv`)
+
case "exec":
// generated from: providers/dns/exec/exec.toml
ew.writeln(`Configuration for External program.`)
diff --git a/docs/content/dns/zz_gen_euserv.md b/docs/content/dns/zz_gen_euserv.md
new file mode 100644
index 000000000..a1116cdd3
--- /dev/null
+++ b/docs/content/dns/zz_gen_euserv.md
@@ -0,0 +1,71 @@
+---
+title: "EUserv"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: euserv
+dnsprovider:
+ since: "v4.32.0"
+ code: "euserv"
+ url: "https://www.euserv.com/en/"
+---
+
+
+
+
+
+
+Configuration for [EUserv](https://www.euserv.com/en/).
+
+
+
+
+- Code: `euserv`
+- Since: v4.32.0
+
+
+Here is an example bash command using the EUserv provider:
+
+```bash
+EUSERV_EMAIL="user@example.com" \
+EUSERV_PASSWORD="xxx" \
+EUSERV_ORDER_ID="yyy" \
+lego --email you@example.com --dns euserv -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EUSERV_EMAIL` | The customer email address. You can also use the customer id instead. |
+| `EUSERV_ORDER_ID` | The order ID of the API contract that you want to use for this login session. |
+| `EUSERV_PASSWORD` | The customer account password. |
+
+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 |
+|--------------------------------|-------------|
+| `EUSERV_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EUSERV_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EUSERV_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `EUSERV_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://support.euserv.com/api-doc/)
+
+
+
+
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index e31633567..7572703a1 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, 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, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, 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, 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, euserv, 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, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, 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/euserv/euserv.go b/providers/dns/euserv/euserv.go
new file mode 100644
index 000000000..e0e7596cf
--- /dev/null
+++ b/providers/dns/euserv/euserv.go
@@ -0,0 +1,231 @@
+// Package euserv implements a DNS provider for solving the DNS-01 challenge using EUserv.
+package euserv
+
+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/euserv/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EUSERV_"
+
+ EnvEmail = envNamespace + "EMAIL"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvOrderID = envNamespace + "ORDER_ID"
+
+ 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 {
+ Email string
+ Password string
+ OrderID 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
+
+ identifier *internal.Identifier
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for EUserv.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvEmail, EnvPassword, EnvOrderID)
+ if err != nil {
+ return nil, fmt.Errorf("euserv: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Email = values[EnvEmail]
+ config.Password = values[EnvPassword]
+ config.OrderID = values[EnvOrderID]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for EUserv.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("euserv: the configuration of the DNS provider is nil")
+ }
+
+ identifier, err := internal.NewIdentifier(config.Email, config.Password, config.OrderID)
+ if err != nil {
+ return nil, fmt.Errorf("euserv: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ identifier.HTTPClient = config.HTTPClient
+ }
+
+ identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient)
+
+ client := internal.NewClient()
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ identifier: identifier,
+ }, 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)
+
+ sessionID, err := d.identifier.Login(ctx)
+ if err != nil {
+ return fmt.Errorf("euserv: login: %w", err)
+ }
+
+ ctx = internal.WithContext(ctx, sessionID)
+
+ defer func() { _ = d.identifier.Logout(ctx) }()
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("euserv: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("euserv: %w", err)
+ }
+
+ domainID, err := d.findDomainID(ctx, authZone, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("euserv: find domain ID: %w", err)
+ }
+
+ srRrequest := internal.SetRecordRequest{
+ DomainID: domainID,
+ Subdomain: subDomain,
+ Type: "TXT",
+ Content: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ err = d.client.SetRecord(ctx, srRrequest)
+ if err != nil {
+ return fmt.Errorf("euserv: set 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)
+
+ sessionID, err := d.identifier.Login(ctx)
+ if err != nil {
+ return fmt.Errorf("euserv: login: %w", err)
+ }
+
+ ctx = internal.WithContext(ctx, sessionID)
+
+ defer func() { _ = d.identifier.Logout(ctx) }()
+
+ recordID, err := d.findRecordID(ctx, info)
+ if err != nil {
+ return fmt.Errorf("euserv: find record ID: %w", err)
+ }
+
+ err = d.client.RemoveRecord(ctx, recordID)
+ if err != nil {
+ return fmt.Errorf("euserv: remove 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
+}
+
+func (d *DNSProvider) findRecordID(ctx context.Context, info dns01.ChallengeInfo) (string, error) {
+ grRequest := internal.GetRecordsRequest{
+ Type: "TXT",
+ Content: info.Value,
+ }
+
+ domains, err := d.client.GetRecords(ctx, grRequest)
+ if err != nil {
+ return "", fmt.Errorf("get records: %w", err)
+ }
+
+ for _, domain := range domains {
+ for _, record := range domain.DNSRecords {
+ if record.Type.Value == "TXT" && record.Content.Value == info.Value {
+ return record.ID.Value, nil
+ }
+ }
+ }
+
+ return "", errors.New("record not found")
+}
+
+func (d *DNSProvider) findDomainID(ctx context.Context, authZone, fqdn string) (string, error) {
+ grRequest := internal.GetRecordsRequest{
+ Keyword: authZone,
+ }
+
+ domains, err := d.client.GetRecords(ctx, grRequest)
+ if err != nil {
+ return "", fmt.Errorf("get records: %w", err)
+ }
+
+ for a := range dns01.UnFqdnDomainsSeq(fqdn) {
+ for _, b := range domains {
+ if b.Domain.Value == a {
+ return b.ID.Value, nil
+ }
+ }
+ }
+
+ return "", errors.New("domain not found")
+}
diff --git a/providers/dns/euserv/euserv.toml b/providers/dns/euserv/euserv.toml
new file mode 100644
index 000000000..bc1003435
--- /dev/null
+++ b/providers/dns/euserv/euserv.toml
@@ -0,0 +1,26 @@
+Name = "EUserv"
+Description = ''''''
+URL = "https://www.euserv.com/en/"
+Code = "euserv"
+Since = "v4.32.0"
+
+Example = '''
+EUSERV_EMAIL="user@example.com" \
+EUSERV_PASSWORD="xxx" \
+EUSERV_ORDER_ID="yyy" \
+lego --email you@example.com --dns euserv -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EUSERV_EMAIL = "The customer email address. You can also use the customer id instead."
+ EUSERV_PASSWORD = "The customer account password."
+ EUSERV_ORDER_ID = "The order ID of the API contract that you want to use for this login session."
+ [Configuration.Additional]
+ EUSERV_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EUSERV_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EUSERV_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ EUSERV_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://support.euserv.com/api-doc/"
diff --git a/providers/dns/euserv/euserv_test.go b/providers/dns/euserv/euserv_test.go
new file mode 100644
index 000000000..5d5eecf30
--- /dev/null
+++ b/providers/dns/euserv/euserv_test.go
@@ -0,0 +1,176 @@
+package euserv
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvEmail,
+ EnvPassword,
+ EnvOrderID,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvEmail: "email@example.com",
+ EnvPassword: "secret",
+ EnvOrderID: "orderA",
+ },
+ },
+ {
+ desc: "missing email",
+ envVars: map[string]string{
+ EnvEmail: "",
+ EnvPassword: "secret",
+ EnvOrderID: "orderA",
+ },
+ expected: "euserv: some credentials information are missing: EUSERV_EMAIL",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvEmail: "email@example.com",
+ EnvPassword: "",
+ EnvOrderID: "orderA",
+ },
+ expected: "euserv: some credentials information are missing: EUSERV_PASSWORD",
+ },
+ {
+ desc: "missing order ID",
+ envVars: map[string]string{
+ EnvEmail: "email@example.com",
+ EnvPassword: "secret",
+ EnvOrderID: "",
+ },
+ expected: "euserv: some credentials information are missing: EUSERV_ORDER_ID",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "euserv: some credentials information are missing: EUSERV_EMAIL,EUSERV_PASSWORD,EUSERV_ORDER_ID",
+ },
+ }
+
+ 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
+ email string
+ password string
+ orderID string
+ expected string
+ }{
+ {
+ desc: "success",
+ email: "email@example.com",
+ password: "secret",
+ orderID: "orderA",
+ },
+ {
+ desc: "missing email",
+ email: "",
+ password: "secret",
+ orderID: "orderA",
+ expected: "euserv: credentials missing",
+ },
+ {
+ desc: "missing password",
+ email: "email@example.com",
+ password: "",
+ orderID: "orderA",
+ expected: "euserv: credentials missing",
+ },
+ {
+ desc: "missing order ID",
+ email: "email@example.com",
+ password: "secret",
+ orderID: "",
+ expected: "euserv: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "euserv: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Email = test.email
+ config.Password = test.password
+ config.OrderID = test.orderID
+
+ 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)
+}
diff --git a/providers/dns/euserv/internal/client.go b/providers/dns/euserv/internal/client.go
new file mode 100644
index 000000000..4ad0c9891
--- /dev/null
+++ b/providers/dns/euserv/internal/client.go
@@ -0,0 +1,214 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "maps"
+ "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"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const defaultBaseURL = "https://support.euserv.com"
+
+// Client the EUserv API client.
+type Client struct {
+ BaseURL string
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient() *Client {
+ return &Client{
+ BaseURL: defaultBaseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+// GetRecords gets a data list of DNS record data for all domains in an order.
+// https://support.euserv.com/api-doc/#api-Domain-kc2_domain_dns_get_records
+func (c *Client) GetRecords(ctx context.Context, request GetRecordsRequest) ([]Domain, error) {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ query := endpoint.Query()
+ query.Set("subaction", "kc2_domain_dns_get_records")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newHTTPRequest(ctx, endpoint, request)
+ if err != nil {
+ return nil, err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ result, err := extractResponse[Records](response)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Domains, nil
+}
+
+// RemoveRecord removes a DNS record.
+// https://support.euserv.com/api-doc/#api-Domain-kc2_domain_dns_remove
+func (c *Client) RemoveRecord(ctx context.Context, recordID string) error {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return err
+ }
+
+ query := endpoint.Query()
+ query.Set("subaction", "kc2_domain_dns_remove")
+ query.Set("dns_record_id", recordID)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newHTTPRequest(ctx, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return err
+ }
+
+ _, err = extractResponse[Session](response)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// SetRecord create or updates a DNS record.
+// https://support.euserv.com/api-doc/#api-Domain-kc2_domain_dns_set
+func (c *Client) SetRecord(ctx context.Context, request SetRecordRequest) error {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return err
+ }
+
+ query := endpoint.Query()
+ query.Set("subaction", "kc2_domain_dns_set")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newHTTPRequest(ctx, endpoint, request)
+ if err != nil {
+ return err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return err
+ }
+
+ _, err = extractResponse[Session](response)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) do(req *http.Request, result any) 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 nil
+}
+
+func newHTTPRequest(ctx context.Context, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ query := endpoint.Query()
+ query.Set("method", "json")
+ query.Set("lang_id", "2")
+
+ sessionID := getSessionID(ctx)
+ if sessionID != "" {
+ query.Set("sess_id", sessionID)
+ }
+
+ if payload != nil {
+ values, err := querystring.Values(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ maps.Copy(query, values)
+ }
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ return req, nil
+}
+
+func extractResponse[T any](response APIResponse) (T, error) {
+ if response.Code != "100" {
+ var zero T
+
+ return zero, &APIError{APIResponse: response}
+ }
+
+ var result T
+
+ err := json.Unmarshal(response.Result, &result)
+ if err != nil {
+ var zero T
+ return zero, fmt.Errorf("unable to unmarshal response: %s, %w", string(response.Result), err)
+ }
+
+ return result, nil
+}
diff --git a/providers/dns/euserv/internal/client_test.go b/providers/dns/euserv/internal/client_test.go
new file mode 100644
index 000000000..748f6960f
--- /dev/null
+++ b/providers/dns/euserv/internal/client_test.go
@@ -0,0 +1,128 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "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 := NewClient()
+
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_RemoveRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("dns_remove.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("subaction", "kc2_domain_dns_remove").
+ With("dns_record_id", "a1b2c3").
+ With("lang_id", "2").
+ With("method", "json"),
+ ).
+ Build(t)
+
+ err := client.RemoveRecord(t.Context(), "a1b2c3")
+ require.NoError(t, err)
+}
+
+func TestClient_SetRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("dns_set.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("subaction", "kc2_domain_dns_set").
+ With("dom_id", "a1").
+ With("dns_record_id", "b2").
+ With("subdomain", "foo").
+ With("type", "TXT").
+ With("content", "txtTXTtxt").
+ With("ttl", "120").
+ With("lang_id", "2").
+ With("method", "json"),
+ ).
+ Build(t)
+
+ request := SetRecordRequest{
+ DomainID: "a1",
+ ID: "b2",
+ Subdomain: "foo",
+ Type: "TXT",
+ Content: "txtTXTtxt",
+ TTL: 120,
+ }
+
+ err := client.SetRecord(t.Context(), request)
+ require.NoError(t, err)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("dns_get_records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("subaction", "kc2_domain_dns_get_records").
+ With("dns_records_load_keyword", "bar").
+ With("dns_records_load_only_for_dom_id", "a1").
+ With("dns_records_load_page", "6").
+ With("dns_records_load_subdomain", "foo").
+ With("dns_records_load_type", "TXT").
+ With("lang_id", "2").
+ With("method", "json"),
+ ).
+ Build(t)
+
+ request := GetRecordsRequest{
+ Page: "6",
+ OnlyForDomID: "a1",
+ Keyword: "bar",
+ Subdomain: "foo",
+ Type: "TXT",
+ }
+
+ domains, err := client.GetRecords(t.Context(), request)
+ require.NoError(t, err)
+
+ expected := []Domain{{
+ ID: APIValue{Value: "1234"},
+ Domain: APIValue{Value: "mydomain.com"},
+ NameserversUsingDefault: APIValue{Value: "1"},
+ DNSRecordCount: APIValue{Value: "2"},
+ DNSRecords: []DNSRecord{
+ {
+ ID: APIValue{Value: "123456"},
+ Name: APIValue{Value: "*.mydomain.com"},
+ Type: APIValue{Value: "A"},
+ Subdomain: APIValue{Value: "*"},
+ Content: APIValue{Value: "XX.XX.XX.XX"},
+ TTL: APIValue{Value: "86400"},
+ Priority: APIValue{Value: ""},
+ },
+ {
+ ID: APIValue{Value: "234567"},
+ Name: APIValue{Value: "mydomain.com"},
+ Type: APIValue{Value: "MX"},
+ Subdomain: APIValue{Value: ""},
+ Content: APIValue{Value: "mx10.kundencontroller.de"},
+ TTL: APIValue{Value: "86400"},
+ Priority: APIValue{Value: "10"},
+ },
+ },
+ }}
+
+ assert.Equal(t, expected, domains)
+}
diff --git a/providers/dns/euserv/internal/fixtures/dns_get_records.json b/providers/dns/euserv/internal/fixtures/dns_get_records.json
new file mode 100644
index 000000000..44d3b4b38
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/dns_get_records.json
@@ -0,0 +1,82 @@
+{
+ "message": "success",
+ "code": "100",
+ "result": {
+ "dns_record_count_max": {
+ "value": "100"
+ },
+ "dns_record_count_total": {
+ "value": "2"
+ },
+ "dns_records_load_page": {
+ "value": "1"
+ },
+ "domains": [
+ {
+ "dom_id": {
+ "value": "1234"
+ },
+ "dom_domain": {
+ "value": "mydomain.com"
+ },
+ "nameservers_using_default": {
+ "value": "1"
+ },
+ "dns_record_count": {
+ "value": "2"
+ },
+ "dns_records": [
+ {
+ "id": {
+ "value": "123456"
+ },
+ "name": {
+ "value": "*.mydomain.com"
+ },
+ "type": {
+ "value": "A"
+ },
+ "subdomain": {
+ "value": "*"
+ },
+ "content": {
+ "value": "XX.XX.XX.XX"
+ },
+ "ttl": {
+ "value": "86400"
+ },
+ "prio": {
+ "value": ""
+ }
+ },
+ {
+ "id": {
+ "value": "234567"
+ },
+ "name": {
+ "value": "mydomain.com"
+ },
+ "type": {
+ "value": "MX"
+ },
+ "subdomain": {
+ "value": ""
+ },
+ "content": {
+ "value": "mx10.kundencontroller.de"
+ },
+ "ttl": {
+ "value": "86400"
+ },
+ "prio": {
+ "value": "10"
+ }
+ }
+ ]
+ }
+ ],
+ "sess_id": {
+ "value": "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942"
+ }
+ }
+}
diff --git a/providers/dns/euserv/internal/fixtures/dns_remove.json b/providers/dns/euserv/internal/fixtures/dns_remove.json
new file mode 100644
index 000000000..317804b38
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/dns_remove.json
@@ -0,0 +1,9 @@
+{
+ "message": "success",
+ "code": "100",
+ "result": {
+ "sess_id": {
+ "value": "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942"
+ }
+ }
+}
diff --git a/providers/dns/euserv/internal/fixtures/dns_set.json b/providers/dns/euserv/internal/fixtures/dns_set.json
new file mode 100644
index 000000000..317804b38
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/dns_set.json
@@ -0,0 +1,9 @@
+{
+ "message": "success",
+ "code": "100",
+ "result": {
+ "sess_id": {
+ "value": "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942"
+ }
+ }
+}
diff --git a/providers/dns/euserv/internal/fixtures/error.json b/providers/dns/euserv/internal/fixtures/error.json
new file mode 100644
index 000000000..2c3a5fc27
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/error.json
@@ -0,0 +1,12 @@
+{
+ "message": "Login failed.
Please check email address\/customer ID and password.",
+ "code": "10006",
+ "time_real": "0.18262982368469",
+ "time_user": "0.083987",
+ "time_sys": "0.017997",
+ "result": {
+ "sess_id": {
+ "value": "b5bde6eaa89a12735bb39a3ff46557e7781201291131766513223"
+ }
+ }
+}
diff --git a/providers/dns/euserv/internal/fixtures/login.json b/providers/dns/euserv/internal/fixtures/login.json
new file mode 100644
index 000000000..317804b38
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/login.json
@@ -0,0 +1,9 @@
+{
+ "message": "success",
+ "code": "100",
+ "result": {
+ "sess_id": {
+ "value": "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942"
+ }
+ }
+}
diff --git a/providers/dns/euserv/internal/fixtures/logout.json b/providers/dns/euserv/internal/fixtures/logout.json
new file mode 100644
index 000000000..b0662603b
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/logout.json
@@ -0,0 +1,8 @@
+{
+ "message": "success",
+ "code": "100",
+ "time_real": "0.10037302970886",
+ "time_user": "0.075988",
+ "time_sys": "0.018997",
+ "result": []
+}
diff --git a/providers/dns/euserv/internal/fixtures/session.json b/providers/dns/euserv/internal/fixtures/session.json
new file mode 100644
index 000000000..317804b38
--- /dev/null
+++ b/providers/dns/euserv/internal/fixtures/session.json
@@ -0,0 +1,9 @@
+{
+ "message": "success",
+ "code": "100",
+ "result": {
+ "sess_id": {
+ "value": "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942"
+ }
+ }
+}
diff --git a/providers/dns/euserv/internal/identity.go b/providers/dns/euserv/internal/identity.go
new file mode 100644
index 000000000..65a025314
--- /dev/null
+++ b/providers/dns/euserv/internal/identity.go
@@ -0,0 +1,196 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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 defaultAPIVersion = "2.14.2-0"
+
+type sessionIDKeyType string
+
+const sessionIDKey sessionIDKeyType = "sessionID"
+
+type Identifier struct {
+ email string
+ password string
+ orderID string
+
+ BaseURL string
+ HTTPClient *http.Client
+}
+
+func NewIdentifier(email, password, orderID string) (*Identifier, error) {
+ if email == "" || password == "" || orderID == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ return &Identifier{
+ email: email,
+ password: password,
+ orderID: orderID,
+ BaseURL: defaultBaseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// Login creates a new session and performs a customer login.
+func (c *Identifier) Login(ctx context.Context) (string, error) {
+ sessionID, err := c.getSessionID(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ return c.login(WithContext(ctx, sessionID), LoginRequest{
+ Email: c.email,
+ Password: c.password,
+ OrderID: c.orderID,
+ APIVersion: defaultAPIVersion,
+ })
+}
+
+// login performs a customer login.
+// https://support.euserv.com/api-doc/#api-Customer-login
+func (c *Identifier) login(ctx context.Context, request LoginRequest) (string, error) {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return "", err
+ }
+
+ query := endpoint.Query()
+ query.Set("subaction", "login")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newHTTPRequest(ctx, endpoint, request)
+ if err != nil {
+ return "", err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return "", err
+ }
+
+ data, err := extractResponse[Session](response)
+ if err != nil {
+ return "", err
+ }
+
+ return data.ID.Value, nil
+}
+
+// Logout performs a customer logout and end the given session.
+// https://support.euserv.com/api-doc/#api-Customer-logout
+func (c *Identifier) Logout(ctx context.Context) error {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return err
+ }
+
+ query := endpoint.Query()
+ query.Set("action", "logout")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newHTTPRequest(ctx, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return err
+ }
+
+ _, err = extractResponse[any](response)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// getSessionID gets a new session id.
+// https://support.euserv.com/api-doc/#api-Session-Get_a_new_session_id
+func (c *Identifier) getSessionID(ctx context.Context) (string, error) {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return "", err
+ }
+
+ req, err := newHTTPRequest(ctx, endpoint, nil)
+ if err != nil {
+ return "", err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return "", err
+ }
+
+ data, err := extractResponse[Session](response)
+ if err != nil {
+ return "", err
+ }
+
+ return data.ID.Value, nil
+}
+
+func (c *Identifier) do(req *http.Request, result any) 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 nil
+}
+
+func WithContext(ctx context.Context, id string) context.Context {
+ return context.WithValue(ctx, sessionIDKey, id)
+}
+
+func getSessionID(ctx context.Context) string {
+ id, ok := ctx.Value(sessionIDKey).(string)
+ if !ok {
+ return ""
+ }
+
+ return id
+}
diff --git a/providers/dns/euserv/internal/identity_test.go b/providers/dns/euserv/internal/identity_test.go
new file mode 100644
index 000000000..807075bdc
--- /dev/null
+++ b/providers/dns/euserv/internal/identity_test.go
@@ -0,0 +1,104 @@
+package internal
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupIdentifierClient(server *httptest.Server) (*Identifier, error) {
+ client, err := NewIdentifier("email@example.com", "secret", "orderA")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), sessionIDKey, "aae22b4573276b6c14e72da4c66f9546781201291131766515229")
+}
+
+func TestIdentifier_getSessionID(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("GET /",
+ servermock.ResponseFromFixture("session.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("lang_id", "2").
+ With("method", "json")).
+ Build(t)
+
+ id, err := identifier.getSessionID(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942", id)
+}
+
+func TestIdentifier_login(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("GET /",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("subaction", "login").
+ With("email", "email@example.com").
+ With("password", "secret").
+ With("ord_id", "orderA").
+ With("api_version", "2.14.2-0").
+ With("sess_id", "aae22b4573276b6c14e72da4c66f9546781201291131766515229").
+ With("lang_id", "2").
+ With("method", "json")).
+ Build(t)
+
+ request := LoginRequest{
+ Email: "email@example.com",
+ Password: "secret",
+ OrderID: "orderA",
+ APIVersion: defaultAPIVersion,
+ }
+
+ id, err := identifier.login(mockContext(t), request)
+ require.NoError(t, err)
+
+ assert.Equal(t, "cd52d8bc00c7ad0ec14abcd8d6f9fd1e1010052291546606942", id)
+}
+
+func TestIdentifier_login_error(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("GET /",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ request := LoginRequest{
+ Email: "email@example.com",
+ Password: "secret",
+ OrderID: "orderA",
+ APIVersion: defaultAPIVersion,
+ }
+
+ _, err := identifier.login(mockContext(t), request)
+ require.EqualError(t, err, "10006: Login failed.
Please check email address/customer ID and password.")
+}
+
+func TestIdentifier_Logout(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("GET /",
+ servermock.ResponseFromFixture("logout.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("action", "logout").
+ With("lang_id", "2").
+ With("sess_id", "aae22b4573276b6c14e72da4c66f9546781201291131766515229").
+ With("method", "json")).
+ Build(t)
+
+ err := identifier.Logout(mockContext(t))
+ require.NoError(t, err)
+}
diff --git a/providers/dns/euserv/internal/types.go b/providers/dns/euserv/internal/types.go
new file mode 100644
index 000000000..8dbe29c8b
--- /dev/null
+++ b/providers/dns/euserv/internal/types.go
@@ -0,0 +1,79 @@
+package internal
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+type APIError struct {
+ APIResponse
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%s: %s", a.Code, a.Message)
+}
+
+type APIResponse struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Result json.RawMessage `json:"result"`
+}
+
+type APIValue struct {
+ Value string `json:"value"`
+}
+
+type Session struct {
+ ID APIValue `json:"sess_id"`
+}
+
+type LoginRequest struct {
+ Email string `url:"email,omitempty"`
+ Password string `url:"password,omitempty"`
+ OrderID string `url:"ord_id,omitempty"`
+ APIVersion string `url:"api_version,omitempty"`
+}
+
+type GetRecordsRequest struct {
+ Page string `url:"dns_records_load_page,omitempty"`
+ OnlyForDomID string `url:"dns_records_load_only_for_dom_id,omitempty"`
+ Keyword string `url:"dns_records_load_keyword,omitempty"`
+ Subdomain string `url:"dns_records_load_subdomain,omitempty"`
+ Content string `url:"dns_records_load_content,omitempty"`
+ Type string `url:"dns_records_load_type,omitempty"`
+}
+
+type Records struct {
+ CountMax APIValue `json:"dns_record_count_max"`
+ CountTotal APIValue `json:"dns_record_count_total"`
+ LoadPage APIValue `json:"dns_records_load_page"`
+ Domains []Domain `json:"domains"`
+}
+
+type Domain struct {
+ ID APIValue `json:"dom_id"`
+ Domain APIValue `json:"dom_domain"`
+ NameserversUsingDefault APIValue `json:"nameservers_using_default"`
+ DNSRecordCount APIValue `json:"dns_record_count"`
+ DNSRecords []DNSRecord `json:"dns_records"`
+}
+
+type DNSRecord struct {
+ ID APIValue `json:"id"`
+ Name APIValue `json:"name"`
+ Type APIValue `json:"type"`
+ Subdomain APIValue `json:"subdomain"`
+ Content APIValue `json:"content"`
+ TTL APIValue `json:"ttl"`
+ Priority APIValue `json:"prio"`
+}
+
+type SetRecordRequest struct {
+ DomainID string `url:"dom_id,omitempty"`
+ ID string `url:"dns_record_id,omitempty"`
+ Subdomain string `url:"subdomain,omitempty"`
+ Type string `url:"type,omitempty"`
+ Content string `url:"content,omitempty"`
+ TTL int `url:"ttl,omitempty"`
+ Priority int `url:"prio,omitempty"`
+}
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index ae41f6a20..24031d643 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -66,6 +66,7 @@ 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/euserv"
"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"
@@ -316,6 +317,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return efficientip.NewDNSProvider()
case "epik":
return epik.NewDNSProvider()
+ case "euserv":
+ return euserv.NewDNSProvider()
case "exec":
return exec.NewDNSProvider()
case "exoscale":