From d7f39ed321b9131e1dac1d662958d08c1e535bc5 Mon Sep 17 00:00:00 2001 From: radek-sprta Date: Fri, 24 Apr 2020 03:13:25 +0200 Subject: [PATCH] Add DNS provider for CloudDNS. (#1106) --- README.md | 32 +-- cmd/zz_gen_cmd_dnshelp.go | 23 ++ docs/content/dns/zz_gen_clouddns.md | 66 +++++ providers/dns/clouddns/clouddns.go | 137 +++++++++ providers/dns/clouddns/clouddns.toml | 28 ++ providers/dns/clouddns/clouddns_test.go | 171 ++++++++++++ providers/dns/clouddns/internal/client.go | 262 ++++++++++++++++++ .../dns/clouddns/internal/client_test.go | 130 +++++++++ providers/dns/clouddns/internal/models.go | 74 +++++ providers/dns/dns_providers.go | 3 + 10 files changed, 910 insertions(+), 16 deletions(-) create mode 100644 docs/content/dns/zz_gen_clouddns.md create mode 100644 providers/dns/clouddns/clouddns.go create mode 100644 providers/dns/clouddns/clouddns.toml create mode 100644 providers/dns/clouddns/clouddns_test.go create mode 100644 providers/dns/clouddns/internal/client.go create mode 100644 providers/dns/clouddns/internal/client_test.go create mode 100644 providers/dns/clouddns/internal/models.go diff --git a/README.md b/README.md index f9bb45a3..3e5e8ed6 100644 --- a/README.md +++ b/README.md @@ -47,21 +47,21 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). |---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------| | [Alibaba Cloud DNS](https://go-acme.github.io/lego/dns/alidns/) | [Amazon Lightsail](https://go-acme.github.io/lego/dns/lightsail/) | [Amazon Route 53](https://go-acme.github.io/lego/dns/route53/) | [Aurora DNS](https://go-acme.github.io/lego/dns/auroradns/) | | [Autodns](https://go-acme.github.io/lego/dns/autodns/) | [Azure](https://go-acme.github.io/lego/dns/azure/) | [Bindman](https://go-acme.github.io/lego/dns/bindman/) | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/) | -| [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/) | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/) | [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/) | -| [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Constellix](https://go-acme.github.io/lego/dns/constellix/) | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) | -| [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) | [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod](https://go-acme.github.io/lego/dns/dnspod/) | [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) | -| [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) | [Dyn](https://go-acme.github.io/lego/dns/dyn/) | [Dynu](https://go-acme.github.io/lego/dns/dynu/) | -| [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS](https://go-acme.github.io/lego/dns/fastdns/) | -| [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | -| [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | -| [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | -| [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | -| [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | -| [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | -| [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | -| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | -| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | -| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | -| [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | +| [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/) | [CloudDNS](https://go-acme.github.io/lego/dns/clouddns/) | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/) | +| [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/) | [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Constellix](https://go-acme.github.io/lego/dns/constellix/) | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | +| [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) | [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) | [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod](https://go-acme.github.io/lego/dns/dnspod/) | +| [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) | [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) | [Dyn](https://go-acme.github.io/lego/dns/dyn/) | +| [Dynu](https://go-acme.github.io/lego/dns/dynu/) | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | +| [FastDNS](https://go-acme.github.io/lego/dns/fastdns/) | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | +| [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | +| [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | +| [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | +| [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | +| [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | +| [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | +| [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | +| [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | +| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index d9c810be..0e3723ce 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -22,6 +22,7 @@ func allDNSCodes() string { "bindman", "bluecat", "checkdomain", + "clouddns", "cloudflare", "cloudns", "cloudxns", @@ -262,6 +263,28 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/checkdomain`) + case "clouddns": + // generated from: providers/dns/clouddns/clouddns.toml + ew.writeln(`Configuration for CloudDNS.`) + ew.writeln(`Code: 'clouddns'`) + ew.writeln(`Since: 'v3.6.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "CLOUDDNS_CLIENT_ID": Client ID`) + ew.writeln(` - "CLOUDDNS_EMAIL": Account email`) + ew.writeln(` - "CLOUDDNS_PASSWORD": Account password`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "CLOUDDNS_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "CLOUDDNS_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "CLOUDDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "CLOUDDNS_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/clouddns`) + case "cloudflare": // generated from: providers/dns/cloudflare/cloudflare.toml ew.writeln(`Configuration for Cloudflare.`) diff --git a/docs/content/dns/zz_gen_clouddns.md b/docs/content/dns/zz_gen_clouddns.md new file mode 100644 index 00000000..cd2d244b --- /dev/null +++ b/docs/content/dns/zz_gen_clouddns.md @@ -0,0 +1,66 @@ +--- +title: "CloudDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: clouddns +--- + + + + + +Since: v3.6.0 + +Configuration for [CloudDNS](https://vshosting.eu/). + + + + +- Code: `clouddns` + +Here is an example bash command using the CloudDNS provider: + +```bash +CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \ +CLOUDDNS_EMAIL=foo@bar.com \ +CLOUDDNS_PASSWORD=b9841238feb177a84330f \ +lego --dns clouddns --domains my.domain.com --email my@email.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `CLOUDDNS_CLIENT_ID` | Client ID | +| `CLOUDDNS_EMAIL` | Account email | +| `CLOUDDNS_PASSWORD` | Account password | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `CLOUDDNS_HTTP_TIMEOUT` | API request timeout | +| `CLOUDDNS_POLLING_INTERVAL` | Time between DNS propagation check | +| `CLOUDDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `CLOUDDNS_TTL` | The TTL of the TXT record used for the DNS challenge | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + + + +## More information + +- [API documentation](https://admin.vshosting.cloud/clouddns/swagger/) + + + + diff --git a/providers/dns/clouddns/clouddns.go b/providers/dns/clouddns/clouddns.go new file mode 100644 index 00000000..df395530 --- /dev/null +++ b/providers/dns/clouddns/clouddns.go @@ -0,0 +1,137 @@ +// Package clouddns implements a DNS provider for solving the DNS-01 challenge using CloudDNS API. +package clouddns + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v3/challenge/dns01" + "github.com/go-acme/lego/v3/platform/config/env" + "github.com/go-acme/lego/v3/providers/dns/clouddns/internal" +) + +// Environment variables names. +const ( + envNamespace = "CLOUDDNS_" + + EnvClientID = envNamespace + "CLIENT_ID" + EnvEmail = envNamespace + "EMAIL" + EnvPassword = envNamespace + "PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the DNSProvider. +type Config struct { + ClientID string + Email string + Password string + + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider is an implementation of the challenge. Provider interface +// that uses CloudDNS API to manage TXT records for a domain. +type DNSProvider struct { + client *internal.Client + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for CloudDNS. +// Credentials must be passed in the environment variables: +// CLOUDDNS_CLIENT_ID, CLOUDDNS_EMAIL, CLOUDDNS_PASSWORD. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvClientID, EnvEmail, EnvPassword) + if err != nil { + return nil, fmt.Errorf("clouddns: %w", err) + } + + config := NewDefaultConfig() + config.ClientID = values[EnvClientID] + config.Email = values[EnvEmail] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for CloudDNS +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("clouddns: the configuration of the DNS provider is nil") + } + + if config.ClientID == "" || config.Email == "" || config.Password == "" { + return nil, errors.New("clouddns: credentials missing") + } + + client := internal.NewClient(config.ClientID, config.Email, config.Password, config.TTL) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + client: client, + config: config, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("clouddns: %w", err) + } + + err = d.client.AddRecord(authZone, fqdn, value) + if err != nil { + return fmt.Errorf("clouddns: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("clouddns: %w", err) + } + + err = d.client.DeleteRecord(authZone, fqdn) + if err != nil { + return fmt.Errorf("clouddns: %w", err) + } + + return nil +} diff --git a/providers/dns/clouddns/clouddns.toml b/providers/dns/clouddns/clouddns.toml new file mode 100644 index 00000000..bbc0d354 --- /dev/null +++ b/providers/dns/clouddns/clouddns.toml @@ -0,0 +1,28 @@ +Name = "CloudDNS" +Description = '''''' +URL = "https://vshosting.eu/" +Code = "clouddns" +Since = "v3.6.0" + +Example = ''' +CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \ +CLOUDDNS_EMAIL=foo@bar.com \ +CLOUDDNS_PASSWORD=b9841238feb177a84330f \ +lego --dns clouddns --domains my.domain.com --email my@email.com run +''' + +[Configuration] + [Configuration.Credentials] + CLOUDDNS_CLIENT_ID = "Client ID" + CLOUDDNS_EMAIL = "Account email" + CLOUDDNS_PASSWORD = "Account password" + [Configuration.Additional] + CLOUDDNS_POLLING_INTERVAL = "Time between DNS propagation check" + CLOUDDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + CLOUDDNS_TTL = "The TTL of the TXT record used for the DNS challenge" + CLOUDDNS_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://admin.vshosting.cloud/clouddns/swagger/" + APIAdmin = "https://admin.vshosting.cloud/api/public/swagger/" + Documentation = "https://github.com/vshosting/clouddns" diff --git a/providers/dns/clouddns/clouddns_test.go b/providers/dns/clouddns/clouddns_test.go new file mode 100644 index 00000000..0822149f --- /dev/null +++ b/providers/dns/clouddns/clouddns_test.go @@ -0,0 +1,171 @@ +package clouddns + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v3/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvClientID, + EnvEmail, + EnvPassword). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvClientID: "client123", + EnvEmail: "test@example.com", + EnvPassword: "password123", + }, + }, + { + desc: "missing clientId", + envVars: map[string]string{ + EnvClientID: "", + EnvEmail: "test@example.com", + EnvPassword: "password123", + }, + expected: "clouddns: some credentials information are missing: CLOUDDNS_CLIENT_ID", + }, + { + desc: "missing email", + envVars: map[string]string{ + EnvClientID: "client123", + EnvEmail: "", + EnvPassword: "password123", + }, + expected: "clouddns: some credentials information are missing: CLOUDDNS_EMAIL", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvClientID: "client123", + EnvEmail: "test@example.com", + EnvPassword: "", + }, + expected: "clouddns: some credentials information are missing: CLOUDDNS_PASSWORD", + }, + } + + 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 len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + clientID string + email string + password string + expected string + }{ + { + desc: "success", + clientID: "ID", + email: "test@example.com", + password: "secret", + }, + { + desc: "missing credentials", + expected: "clouddns: credentials missing", + }, + { + desc: "missing client ID", + clientID: "", + email: "test@example.com", + password: "secret", + expected: "clouddns: credentials missing", + }, + { + desc: "missing email", + clientID: "ID", + email: "", + password: "secret", + expected: "clouddns: credentials missing", + }, + { + desc: "missing password", + clientID: "ID", + email: "test@example.com", + password: "", + expected: "clouddns: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ClientID = test.clientID + config.Email = test.email + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/clouddns/internal/client.go b/providers/dns/clouddns/internal/client.go new file mode 100644 index 00000000..5010bdb6 --- /dev/null +++ b/providers/dns/clouddns/internal/client.go @@ -0,0 +1,262 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" +) + +const ( + apiBaseURL = "https://admin.vshosting.cloud/clouddns" + loginURL = "https://admin.vshosting.cloud/api/public/auth/login" +) + +// Client handles all communication with CloudDNS API. +type Client struct { + AccessToken string + ClientID string + Email string + Password string + TTL int + HTTPClient *http.Client + + apiBaseURL string + loginURL string +} + +// NewClient returns a Client instance configured to handle CloudDNS API communication. +func NewClient(clientID string, email string, password string, ttl int) *Client { + return &Client{ + ClientID: clientID, + Email: email, + Password: password, + TTL: ttl, + HTTPClient: &http.Client{}, + apiBaseURL: apiBaseURL, + loginURL: loginURL, + } +} + +// AddRecord is a high level method to add a new record into CloudDNS zone. +func (c *Client) AddRecord(zone, recordName, recordValue string) error { + domain, err := c.getDomain(zone) + if err != nil { + return err + } + + record := Record{DomainID: domain.ID, Name: recordName, Value: recordValue, Type: "TXT"} + + err = c.addTxtRecord(record) + if err != nil { + return err + } + + return c.publishRecords(domain.ID) +} + +// DeleteRecord is a high level method to remove a record from zone. +func (c *Client) DeleteRecord(zone, recordName string) error { + domain, err := c.getDomain(zone) + if err != nil { + return err + } + + record, err := c.getRecord(domain.ID, recordName) + if err != nil { + return err + } + + err = c.deleteRecord(record) + if err != nil { + return err + } + + return c.publishRecords(domain.ID) +} + +func (c *Client) addTxtRecord(record Record) error { + body, err := json.Marshal(record) + if err != nil { + return err + } + + _, err = c.doAPIRequest(http.MethodPost, "record-txt", bytes.NewReader(body)) + return err +} + +func (c *Client) deleteRecord(record Record) error { + endpoint := fmt.Sprintf("record/%s", record.ID) + _, err := c.doAPIRequest(http.MethodDelete, endpoint, nil) + return err +} + +func (c *Client) getDomain(zone string) (Domain, error) { + searchQuery := SearchQuery{ + Search: []Search{ + {Name: "clientId", Operator: "eq", Value: c.ClientID}, + {Name: "domainName", Operator: "eq", Value: zone}, + }, + } + + body, err := json.Marshal(searchQuery) + if err != nil { + return Domain{}, err + } + + resp, err := c.doAPIRequest(http.MethodPost, "domain/search", bytes.NewReader(body)) + if err != nil { + return Domain{}, err + } + + var result SearchResponse + err = json.Unmarshal(resp, &result) + if err != nil { + return Domain{}, err + } + + if len(result.Items) == 0 { + return Domain{}, fmt.Errorf("domain not found: %s", zone) + } + + return result.Items[0], nil +} + +func (c *Client) getRecord(domainID, recordName string) (Record, error) { + endpoint := fmt.Sprintf("domain/%s", domainID) + resp, err := c.doAPIRequest(http.MethodGet, endpoint, nil) + if err != nil { + return Record{}, err + } + + var result DomainInfo + err = json.Unmarshal(resp, &result) + if err != nil { + return Record{}, err + } + + for _, record := range result.LastDomainRecordList { + if record.Name == recordName && record.Type == "TXT" { + return record, nil + } + } + + return Record{}, fmt.Errorf("record not found: domainID %s, name %s", domainID, recordName) +} + +func (c *Client) publishRecords(domainID string) error { + body, err := json.Marshal(DomainInfo{SoaTTL: c.TTL}) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("domain/%s/publish", domainID) + _, err = c.doAPIRequest(http.MethodPut, endpoint, bytes.NewReader(body)) + return err +} + +func (c *Client) login() error { + authorization := Authorization{Email: c.Email, Password: c.Password} + + body, err := json.Marshal(authorization) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, c.loginURL, bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + + content, err := c.doRequest(req) + if err != nil { + return err + } + + var result AuthResponse + err = json.Unmarshal(content, &result) + if err != nil { + return err + } + + c.AccessToken = result.Auth.AccessToken + + return nil +} + +func (c *Client) doAPIRequest(method, endpoint string, body io.Reader) ([]byte, error) { + if c.AccessToken == "" { + err := c.login() + if err != nil { + return nil, err + } + } + + url := fmt.Sprintf("%s/%s", c.apiBaseURL, endpoint) + + req, err := c.newRequest(method, url, body) + if err != nil { + return nil, err + } + + content, err := c.doRequest(req) + if err != nil { + return nil, err + } + + return content, nil +} + +func (c *Client) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.AccessToken)) + + return req, nil +} + +func (c *Client) doRequest(req *http.Request) ([]byte, error) { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, readError(req, resp) + } + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return content, nil +} + +func readError(req *http.Request, resp *http.Response) error { + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return errors.New(toUnreadableBodyMessage(req, content)) + } + + var errInfo APIError + err = json.Unmarshal(content, &errInfo) + if err != nil { + return fmt.Errorf("APIError unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content)) + } + + return fmt.Errorf("HTTP %d: code %v: %s", resp.StatusCode, errInfo.Error.Code, errInfo.Error.Message) +} + +func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { + return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) +} diff --git a/providers/dns/clouddns/internal/client_test.go b/providers/dns/clouddns/internal/client_test.go new file mode 100644 index 00000000..b06637b0 --- /dev/null +++ b/providers/dns/clouddns/internal/client_test.go @@ -0,0 +1,130 @@ +package internal + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClient_AddRecord(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { + response := SearchResponse{ + Items: []Domain{ + { + ID: "A", + DomainName: "example.com", + }, + }, + } + + err := json.NewEncoder(rw).Encode(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/api/record-txt", func(rw http.ResponseWriter, req *http.Request) {}) + mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {}) + mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { + response := AuthResponse{ + Auth: Auth{ + AccessToken: "at", + RefreshToken: "", + }, + } + + err := json.NewEncoder(rw).Encode(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + server := httptest.NewServer(mux) + t.Cleanup(func() { + server.Close() + }) + + client := NewClient("clientID", "email@example.com", "secret", 300) + client.apiBaseURL = server.URL + "/api" + client.loginURL = server.URL + "/login" + + err := client.AddRecord("example.com", "_acme-challenge.example.com", "txt") + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { + response := SearchResponse{ + Items: []Domain{ + { + ID: "A", + DomainName: "example.com", + }, + }, + } + + err := json.NewEncoder(rw).Encode(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/api/domain/A", func(rw http.ResponseWriter, req *http.Request) { + response := DomainInfo{ + ID: "Z", + DomainName: "example.com", + LastDomainRecordList: []Record{ + { + ID: "R01", + DomainID: "A", + Name: "_acme-challenge.example.com", + Value: "txt", + Type: "TXT", + }, + }, + SoaTTL: 300, + } + + err := json.NewEncoder(rw).Encode(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/api/record/R01", func(rw http.ResponseWriter, req *http.Request) {}) + mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {}) + mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { + response := AuthResponse{ + Auth: Auth{ + AccessToken: "at", + RefreshToken: "", + }, + } + + err := json.NewEncoder(rw).Encode(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + server := httptest.NewServer(mux) + t.Cleanup(func() { + server.Close() + }) + + client := NewClient("clientID", "email@example.com", "secret", 300) + client.apiBaseURL = server.URL + "/api" + client.loginURL = server.URL + "/login" + + err := client.DeleteRecord("example.com", "_acme-challenge.example.com") + require.NoError(t, err) +} diff --git a/providers/dns/clouddns/internal/models.go b/providers/dns/clouddns/internal/models.go new file mode 100644 index 00000000..a46bfdf0 --- /dev/null +++ b/providers/dns/clouddns/internal/models.go @@ -0,0 +1,74 @@ +package internal + +type APIError struct { + Error ErrorContent `json:"error"` +} + +type ErrorContent struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type Authorization struct { + Email string `json:"email,omitempty"` + Password string `json:"password,omitempty"` +} + +type AuthResponse struct { + Auth Auth `json:"auth,omitempty"` +} + +type Auth struct { + AccessToken string `json:"accessToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` +} + +type SearchQuery struct { + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + Search []Search `json:"search,omitempty"` + Sort []Sort `json:"sort,omitempty"` +} + +// Search used for searches in the CloudDNS API. +type Search struct { + Name string `json:"name,omitempty"` + Operator string `json:"operator,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` +} + +type Sort struct { + Ascending bool `json:"ascending,omitempty"` + Name string `json:"name,omitempty"` +} + +type SearchResponse struct { + Items []Domain `json:"items,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + TotalHits int `json:"totalHits,omitempty"` +} + +type Domain struct { + ID string `json:"id,omitempty"` + DomainName string `json:"domainName,omitempty"` + Status string `json:"status,omitempty"` +} + +// Record represents a DNS record. +type Record struct { + ID string `json:"id,omitempty"` + DomainID string `json:"domainId,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + Type string `json:"type,omitempty"` +} + +type DomainInfo struct { + ID string `json:"id,omitempty"` + DomainName string `json:"domainName,omitempty"` + LastDomainRecordList []Record `json:"lastDomainRecordList,omitempty"` + SoaTTL int `json:"soaTtl,omitempty"` + Status string `json:"status,omitempty"` +} diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index fb87c68e..8a1fddc7 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -13,6 +13,7 @@ import ( "github.com/go-acme/lego/v3/providers/dns/bindman" "github.com/go-acme/lego/v3/providers/dns/bluecat" "github.com/go-acme/lego/v3/providers/dns/checkdomain" + "github.com/go-acme/lego/v3/providers/dns/clouddns" "github.com/go-acme/lego/v3/providers/dns/cloudflare" "github.com/go-acme/lego/v3/providers/dns/cloudns" "github.com/go-acme/lego/v3/providers/dns/cloudxns" @@ -95,6 +96,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return bluecat.NewDNSProvider() case "checkdomain": return checkdomain.NewDNSProvider() + case "clouddns": + return clouddns.NewDNSProvider() case "cloudflare": return cloudflare.NewDNSProvider() case "cloudns":