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