diff --git a/README.md b/README.md index be4dddf9..a9bc8c49 100644 --- a/README.md +++ b/README.md @@ -50,17 +50,17 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [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/) | -| [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/) | | +| [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/) | [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/) | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index f4b93fd1..cd0d2e27 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -36,6 +36,7 @@ func allDNSCodes() string { "dreamhost", "duckdns", "dyn", + "dynu", "easydns", "exec", "exoscale", @@ -563,6 +564,26 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyn`) + case "dynu": + // generated from: providers/dns/dynu/dynu.toml + ew.writeln(`Configuration for Dynu.`) + ew.writeln(`Code: 'dynu'`) + ew.writeln(`Since: 'v3.5.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "DYNU_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "DYNU_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "DYNU_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "DYNU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "DYNU_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/dynu`) + case "easydns": // generated from: providers/dns/easydns/easydns.toml ew.writeln(`Configuration for EasyDNS.`) diff --git a/docs/content/dns/zz_gen_dynu.md b/docs/content/dns/zz_gen_dynu.md new file mode 100644 index 00000000..f707ebb9 --- /dev/null +++ b/docs/content/dns/zz_gen_dynu.md @@ -0,0 +1,62 @@ +--- +title: "Dynu" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: dynu +--- + + + + + +Since: v3.5.0 + +Configuration for [Dynu](https://www.dynu.com/). + + + + +- Code: `dynu` + +Here is an example bash command using the Dynu provider: + +```bash +DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \ +lego --dns dynu --domains my.domain.com --email my@email.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `DYNU_API_KEY` | API key | + +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 | +|--------------------------------|-------------| +| `DYNU_HTTP_TIMEOUT` | API request timeout | +| `DYNU_POLLING_INTERVAL` | Time between DNS propagation check | +| `DYNU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `DYNU_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://www.dynu.com/en-US/Support/API) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index c35bd6b8..adf54d7b 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -27,6 +27,7 @@ import ( "github.com/go-acme/lego/v3/providers/dns/dreamhost" "github.com/go-acme/lego/v3/providers/dns/duckdns" "github.com/go-acme/lego/v3/providers/dns/dyn" + "github.com/go-acme/lego/v3/providers/dns/dynu" "github.com/go-acme/lego/v3/providers/dns/easydns" "github.com/go-acme/lego/v3/providers/dns/exec" "github.com/go-acme/lego/v3/providers/dns/exoscale" @@ -120,6 +121,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return duckdns.NewDNSProvider() case "dyn": return dyn.NewDNSProvider() + case "dynu": + return dynu.NewDNSProvider() case "fastdns": return fastdns.NewDNSProvider() case "easydns": diff --git a/providers/dns/dynu/dynu.go b/providers/dns/dynu/dynu.go new file mode 100644 index 00000000..63e4c752 --- /dev/null +++ b/providers/dns/dynu/dynu.go @@ -0,0 +1,161 @@ +// Package dynu implements a DNS provider for solving the DNS-01 challenge using Dynu DNS. +package dynu + +import ( + "errors" + "fmt" + "net/http" + "strings" + "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/dynu/internal" + "github.com/miekg/dns" +) + +// Environment variables names. +const ( + envNamespace = "DYNU_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 3*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider is an implementation of the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Dynu. +// Credentials must be passed in the environment variables. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("dynu: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Dynu. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dynu: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("dynu: incomplete credentials, missing API key") + } + + tr, err := internal.NewTokenTransport(config.APIKey) + if err != nil { + return nil, fmt.Errorf("dynu: %w", err) + } + + client := internal.NewClient() + client.HTTPClient = tr.Wrap(config.HTTPClient) + + return &DNSProvider{config: config, client: client}, 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) + + rootDomain, err := d.client.GetRootDomain(domain) + if err != nil { + return fmt.Errorf("dynu: could not find root domain for %s: %w", domain, err) + } + + records, err := d.client.GetRecords(dns01.UnFqdn(fqdn), "TXT") + if err != nil { + return fmt.Errorf("dynu: failed to get records for %s: %w", domain, err) + } + + for _, record := range records { + // the record already exist + if record.Hostname == dns01.UnFqdn(fqdn) && record.TextData == value { + return nil + } + } + + record := internal.DNSRecord{ + Type: "TXT", + DomainName: rootDomain.DomainName, + Hostname: dns01.UnFqdn(fqdn), + NodeName: dns01.UnFqdn(strings.TrimSuffix(fqdn, dns.Fqdn(domain))), + TextData: value, + State: true, + TTL: 300, + } + + err = d.client.AddNewRecord(rootDomain.ID, record) + if err != nil { + return fmt.Errorf("dynu: failed to add record to %s: %w", domain, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + rootDomain, err := d.client.GetRootDomain(domain) + if err != nil { + return fmt.Errorf("dynu: could not find root domain for %s: %w", domain, err) + } + + records, err := d.client.GetRecords(dns01.UnFqdn(fqdn), "TXT") + if err != nil { + return fmt.Errorf("dynu: failed to get records for %s: %w", domain, err) + } + + for _, record := range records { + if record.Hostname == dns01.UnFqdn(fqdn) && record.TextData == value { + err = d.client.DeleteRecord(rootDomain.ID, record.ID) + if err != nil { + return fmt.Errorf("dynu: failed to remove TXT record for %s: %w", domain, err) + } + } + } + + return nil +} diff --git a/providers/dns/dynu/dynu.toml b/providers/dns/dynu/dynu.toml new file mode 100644 index 00000000..34b02a37 --- /dev/null +++ b/providers/dns/dynu/dynu.toml @@ -0,0 +1,22 @@ +Name = "Dynu" +Description = '''''' +URL = "https://www.dynu.com/" +Code = "dynu" +Since = "v3.5.0" + +Example = ''' +DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \ +lego --dns dynu --domains my.domain.com --email my@email.com run +''' + +[Configuration] + [Configuration.Credentials] + DYNU_API_KEY = "API key" + [Configuration.Additional] + DYNU_POLLING_INTERVAL = "Time between DNS propagation check" + DYNU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + DYNU_TTL = "The TTL of the TXT record used for the DNS challenge" + DYNU_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://www.dynu.com/en-US/Support/API" diff --git a/providers/dns/dynu/dynu_test.go b/providers/dns/dynu/dynu_test.go new file mode 100644 index 00000000..94c10aaf --- /dev/null +++ b/providers/dns/dynu/dynu_test.go @@ -0,0 +1,119 @@ +package dynu + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v3/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "_DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAPIKey). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "123", + }, + }, + { + desc: "missing api key", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "dynu: some credentials information are missing: DYNU_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if 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 TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + expected string + apiKey string + }{ + { + desc: "success", + apiKey: "api_key", + }, + { + desc: "missing api key", + apiKey: "", + expected: "dynu: incomplete credentials, missing API key", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + 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(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/dynu/internal/auth.go b/providers/dns/dynu/internal/auth.go new file mode 100644 index 00000000..e03f39f1 --- /dev/null +++ b/providers/dns/dynu/internal/auth.go @@ -0,0 +1,64 @@ +package internal + +import ( + "errors" + "net/http" +) + +const apiKeyHeader = "Api-Key" + +// TokenTransport HTTP transport for API authentication. +type TokenTransport struct { + apiKey string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// NewTokenTransport Creates a HTTP transport for API authentication. +func NewTokenTransport(apiKey string) (*TokenTransport, error) { + if apiKey == "" { + return nil, errors.New("credentials missing: API key") + } + + return &TokenTransport{apiKey: apiKey}, nil +} + +// RoundTrip executes a single HTTP transaction +func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + enrichedReq := &http.Request{} + *enrichedReq = *req + + enrichedReq.Header = make(http.Header, len(req.Header)) + for k, s := range req.Header { + enrichedReq.Header[k] = append([]string(nil), s...) + } + + if t.apiKey != "" { + enrichedReq.Header.Add(apiKeyHeader, t.apiKey) + } + + return t.transport().RoundTrip(enrichedReq) +} + +func (t *TokenTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + +// Client Creates a new HTTP client +func (t *TokenTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +// Wrap Wrap a HTTP client Transport with the TokenTransport +func (t *TokenTransport) Wrap(client *http.Client) *http.Client { + backup := client.Transport + t.Transport = backup + client.Transport = t + + return client +} diff --git a/providers/dns/dynu/internal/auth_test.go b/providers/dns/dynu/internal/auth_test.go new file mode 100644 index 00000000..766a933d --- /dev/null +++ b/providers/dns/dynu/internal/auth_test.go @@ -0,0 +1,40 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTokenTransport_success(t *testing.T) { + apiKey := "api" + + transport, err := NewTokenTransport(apiKey) + require.NoError(t, err) + assert.NotNil(t, transport) +} + +func TestNewTokenTransport_missing_credentials(t *testing.T) { + apiKey := "" + + transport, err := NewTokenTransport(apiKey) + require.Error(t, err) + assert.Nil(t, transport) +} + +func TestTokenTransport_RoundTrip(t *testing.T) { + apiKey := "api" + + transport, err := NewTokenTransport(apiKey) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "api", resp.Request.Header.Get(apiKeyHeader)) +} diff --git a/providers/dns/dynu/internal/client.go b/providers/dns/dynu/internal/client.go new file mode 100644 index 00000000..0b714e73 --- /dev/null +++ b/providers/dns/dynu/internal/client.go @@ -0,0 +1,186 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/go-acme/lego/v3/log" +) + +const defaultBaseURL = "https://api.dynu.com/v2" + +type Client struct { + HTTPClient *http.Client + BaseURL string +} + +func NewClient() *Client { + return &Client{ + HTTPClient: http.DefaultClient, + BaseURL: defaultBaseURL, + } +} + +// GetRecords Get DNS records based on a hostname and resource record type. +func (c Client) GetRecords(hostname string, recordType string) ([]DNSRecord, error) { + endpoint, err := c.createEndpoint("dns", "record", hostname) + if err != nil { + return nil, err + } + + query := endpoint.Query() + query.Set("recordType", recordType) + endpoint.RawQuery = query.Encode() + + apiResp := RecordsResponse{} + err = c.doRetry(http.MethodGet, endpoint.String(), nil, &apiResp) + if err != nil { + return nil, err + } + + if apiResp.StatusCode/100 != 2 { + return nil, fmt.Errorf("API error: %w", apiResp.APIException) + } + + return apiResp.DNSRecords, nil +} + +// AddNewRecord Add a new DNS record for DNS service. +func (c Client) AddNewRecord(domainID int64, record DNSRecord) error { + endpoint, err := c.createEndpoint("dns", strconv.FormatInt(domainID, 10), "record") + if err != nil { + return err + } + + reqBody, err := json.Marshal(record) + if err != nil { + return err + } + + apiResp := RecordResponse{} + err = c.doRetry(http.MethodPost, endpoint.String(), reqBody, &apiResp) + if err != nil { + return err + } + + if apiResp.StatusCode/100 != 2 { + return fmt.Errorf("API error: %w", apiResp.APIException) + } + + return nil +} + +// DeleteRecord Remove a DNS record from DNS service. +func (c Client) DeleteRecord(domainID int64, recordID int64) error { + endpoint, err := c.createEndpoint("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10)) + if err != nil { + return err + } + + apiResp := APIException{} + err = c.doRetry(http.MethodDelete, endpoint.String(), nil, &apiResp) + if err != nil { + return err + } + + if apiResp.StatusCode/100 != 2 { + return fmt.Errorf("API error: %w", apiResp) + } + + return nil +} + +// GetRootDomain Get the root domain name based on a hostname. +func (c Client) GetRootDomain(hostname string) (*DNSHostname, error) { + endpoint, err := c.createEndpoint("dns", "getroot", hostname) + if err != nil { + return nil, err + } + + apiResp := DNSHostname{} + err = c.doRetry(http.MethodGet, endpoint.String(), nil, &apiResp) + if err != nil { + return nil, err + } + + if apiResp.StatusCode/100 != 2 { + return nil, fmt.Errorf("API error: %w", apiResp.APIException) + } + + return &apiResp, nil +} + +// doRetry the API is really unstable so we need to retry on EOF. +func (c Client) doRetry(method, url string, body []byte, data interface{}) error { + var resp *http.Response + + ctx, cancel := context.WithCancel(context.Background()) + + operation := func() error { + var reqBody io.Reader + if len(body) > 0 { + reqBody = bytes.NewReader(body) + } + + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + resp, err = c.HTTPClient.Do(req) + if errors.Is(err, io.EOF) { + return err + } + + if err != nil { + cancel() + return fmt.Errorf("client error: %w", err) + } + + return nil + } + + notify := func(err error, duration time.Duration) { + log.Printf("client retries because of %v", err) + } + + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 1 * time.Second + + err := backoff.RetryNotify(operation, backoff.WithContext(bo, ctx), notify) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + all, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + return json.Unmarshal(all, data) +} + +func (c Client) createEndpoint(fragments ...string) (*url.URL, error) { + baseURL, err := url.Parse(c.BaseURL) + if err != nil { + return nil, err + } + + return baseURL.Parse(path.Join(baseURL.Path, path.Join(fragments...))) +} diff --git a/providers/dns/dynu/internal/client_test.go b/providers/dns/dynu/internal/client_test.go new file mode 100644 index 00000000..517dcc2a --- /dev/null +++ b/providers/dns/dynu/internal/client_test.go @@ -0,0 +1,302 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(method string, pattern string, status int, file string) *Client { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) + return + } + + open, err := os.Open(file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(status) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient() + client.BaseURL = server.URL + + return client +} + +func TestGetRootDomain(t *testing.T) { + type expected struct { + domain *DNSHostname + error string + } + + testCases := []struct { + desc string + pattern string + status int + file string + expected expected + }{ + { + desc: "success", + pattern: "/dns/getroot/test.lego.freeddns.org", + status: http.StatusOK, + file: "./fixtures/get_root_domain.json", + expected: expected{ + domain: &DNSHostname{ + APIException: &APIException{ + StatusCode: 200, + }, + ID: 9007481, + DomainName: "lego.freeddns.org", + Hostname: "test.lego.freeddns.org", + Node: "test", + }, + }, + }, + { + desc: "invalid", + pattern: "/dns/getroot/test.lego.freeddns.org", + status: http.StatusNotImplemented, + file: "./fixtures/get_root_domain_invalid.json", + expected: expected{ + error: "API error: 501: Argument Exception: Invalid.", + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + client := setupTest(http.MethodGet, test.pattern, test.status, test.file) + + domain, err := client.GetRootDomain("test.lego.freeddns.org") + + if test.expected.error != "" { + assert.EqualError(t, err, test.expected.error) + return + } + + require.NoError(t, err) + + assert.NotNil(t, domain) + assert.Equal(t, test.expected.domain, domain) + }) + } +} + +func TestGetRecords(t *testing.T) { + type expected struct { + records []DNSRecord + error string + } + + testCases := []struct { + desc string + pattern string + status int + file string + expected expected + }{ + { + desc: "success", + pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + status: http.StatusOK, + file: "./fixtures/get_records.json", + expected: expected{ + records: []DNSRecord{{ + ID: 6041417, + Type: "TXT", + DomainID: 9007481, + DomainName: "lego.freeddns.org", + NodeName: "_acme-challenge", + Hostname: "_acme-challenge.lego.freeddns.org", + State: true, + Content: `_acme-challenge.lego.freeddns.org. 300 IN TXT "txt_txt_txt_txt_txt_txt_txt"`, + TextData: "txt_txt_txt_txt_txt_txt_txt", + TTL: 300, + }, + { + ID: 6041422, + Type: "TXT", + DomainID: 9007481, + DomainName: "lego.freeddns.org", + NodeName: "_acme-challenge", + Hostname: "_acme-challenge.lego.freeddns.org", + State: true, + Content: `_acme-challenge.lego.freeddns.org. 300 IN TXT "txt_txt_txt_txt_txt_txt_txt_2"`, + TextData: "txt_txt_txt_txt_txt_txt_txt_2", + TTL: 300, + }, + }, + }, + }, + { + desc: "empty", + pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + status: http.StatusOK, + file: "./fixtures/get_records_empty.json", + expected: expected{ + records: []DNSRecord{}, + }, + }, + { + desc: "invalid", + pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + status: http.StatusNotImplemented, + file: "./fixtures/get_records_invalid.json", + expected: expected{ + error: "API error: 501: Argument Exception: Invalid.", + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + client := setupTest(http.MethodGet, test.pattern, test.status, test.file) + + records, err := client.GetRecords("_acme-challenge.lego.freeddns.org", "TXT") + + if test.expected.error != "" { + assert.EqualError(t, err, test.expected.error) + return + } + + require.NoError(t, err) + + assert.NotNil(t, records) + assert.Equal(t, test.expected.records, records) + }) + } +} + +func TestAddNewRecord(t *testing.T) { + type expected struct { + error string + } + + testCases := []struct { + desc string + pattern string + status int + file string + expected expected + }{ + { + desc: "success", + pattern: "/dns/9007481/record", + status: http.StatusOK, + file: "./fixtures/add_new_record.json", + }, + { + desc: "invalid", + pattern: "/dns/9007481/record", + status: http.StatusNotImplemented, + file: "./fixtures/add_new_record_invalid.json", + expected: expected{ + error: "API error: 501: Argument Exception: Invalid.", + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + client := setupTest(http.MethodPost, test.pattern, test.status, test.file) + + record := DNSRecord{ + Type: "TXT", + DomainName: "lego.freeddns.org", + Hostname: "_acme-challenge.lego.freeddns.org", + NodeName: "_acme-challenge", + TextData: "txt_txt_txt_txt_txt_txt_txt_2", + State: true, + TTL: 300, + } + + err := client.AddNewRecord(9007481, record) + + if test.expected.error != "" { + assert.EqualError(t, err, test.expected.error) + return + } + + require.NoError(t, err) + }) + } +} + +func TestDeleteRecord(t *testing.T) { + type expected struct { + error string + } + + testCases := []struct { + desc string + pattern string + status int + file string + expected expected + }{ + { + desc: "success", + pattern: "/", + status: http.StatusOK, + file: "./fixtures/delete_record.json", + }, + { + desc: "invalid", + pattern: "/", + status: http.StatusNotImplemented, + file: "./fixtures/delete_record_invalid.json", + expected: expected{ + error: "API error: 501: Argument Exception: Invalid.", + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + client := setupTest(http.MethodDelete, test.pattern, test.status, test.file) + + err := client.DeleteRecord(9007481, 6041418) + + if test.expected.error != "" { + assert.EqualError(t, err, test.expected.error) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/providers/dns/dynu/internal/fixtures/add_new_record.json b/providers/dns/dynu/internal/fixtures/add_new_record.json new file mode 100644 index 00000000..c02b4994 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/add_new_record.json @@ -0,0 +1,14 @@ +{ + "statusCode": 200, + "id": 6041417, + "domainId": 9007481, + "domainName": "lego.freeddns.org", + "nodeName": "_acme-challenge", + "hostname": "_acme-challenge.lego.freeddns.org", + "recordType": "TXT", + "ttl": 300, + "state": true, + "content": "_acme-challenge.lego.freeddns.org. 300 IN TXT \"txt_txt_txt_txt_txt_txt_txt\"", + "updatedOn": "2020-03-10T04:00:36.923", + "textData": "txt_txt_txt_txt_txt_txt_txt" +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/add_new_record_invalid.json b/providers/dns/dynu/internal/fixtures/add_new_record_invalid.json new file mode 100644 index 00000000..6b94ad9f --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/add_new_record_invalid.json @@ -0,0 +1,5 @@ +{ + "statusCode": 501, + "type": "Argument Exception", + "message": "Invalid." +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/delete_record.json b/providers/dns/dynu/internal/fixtures/delete_record.json new file mode 100644 index 00000000..a81f9df1 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/delete_record.json @@ -0,0 +1,3 @@ +{ + "statusCode": 200 +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/delete_record_invalid.json b/providers/dns/dynu/internal/fixtures/delete_record_invalid.json new file mode 100644 index 00000000..6b94ad9f --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/delete_record_invalid.json @@ -0,0 +1,5 @@ +{ + "statusCode": 501, + "type": "Argument Exception", + "message": "Invalid." +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/get_records.json b/providers/dns/dynu/internal/fixtures/get_records.json new file mode 100644 index 00000000..474027d2 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/get_records.json @@ -0,0 +1,31 @@ +{ + "statusCode": 200, + "dnsRecords": [ + { + "id": 6041417, + "domainId": 9007481, + "domainName": "lego.freeddns.org", + "nodeName": "_acme-challenge", + "hostname": "_acme-challenge.lego.freeddns.org", + "recordType": "TXT", + "ttl": 300, + "state": true, + "content": "_acme-challenge.lego.freeddns.org. 300 IN TXT \"txt_txt_txt_txt_txt_txt_txt\"", + "updatedOn": "2020-03-10T04:00:36.923", + "textData": "txt_txt_txt_txt_txt_txt_txt" + }, + { + "id": 6041422, + "domainId": 9007481, + "domainName": "lego.freeddns.org", + "nodeName": "_acme-challenge", + "hostname": "_acme-challenge.lego.freeddns.org", + "recordType": "TXT", + "ttl": 300, + "state": true, + "content": "_acme-challenge.lego.freeddns.org. 300 IN TXT \"txt_txt_txt_txt_txt_txt_txt_2\"", + "updatedOn": "2020-03-10T04:03:17.563", + "textData": "txt_txt_txt_txt_txt_txt_txt_2" + } + ] +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/get_records_empty.json b/providers/dns/dynu/internal/fixtures/get_records_empty.json new file mode 100644 index 00000000..c72cf0d5 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/get_records_empty.json @@ -0,0 +1,4 @@ +{ + "statusCode": 200, + "dnsRecords": [] +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/get_records_invalid.json b/providers/dns/dynu/internal/fixtures/get_records_invalid.json new file mode 100644 index 00000000..6b94ad9f --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/get_records_invalid.json @@ -0,0 +1,5 @@ +{ + "statusCode": 501, + "type": "Argument Exception", + "message": "Invalid." +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/get_root_domain.json b/providers/dns/dynu/internal/fixtures/get_root_domain.json new file mode 100644 index 00000000..5eda5d39 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/get_root_domain.json @@ -0,0 +1,7 @@ +{ + "statusCode": 200, + "id": 9007481, + "domainName": "lego.freeddns.org", + "hostname": "test.lego.freeddns.org", + "node": "test" +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/fixtures/get_root_domain_invalid.json b/providers/dns/dynu/internal/fixtures/get_root_domain_invalid.json new file mode 100644 index 00000000..6b94ad9f --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/get_root_domain_invalid.json @@ -0,0 +1,5 @@ +{ + "statusCode": 501, + "type": "Argument Exception", + "message": "Invalid." +} \ No newline at end of file diff --git a/providers/dns/dynu/internal/model.go b/providers/dns/dynu/internal/model.go new file mode 100644 index 00000000..23ba2dc4 --- /dev/null +++ b/providers/dns/dynu/internal/model.go @@ -0,0 +1,58 @@ +package internal + +import "fmt" + +// APIException defines model for apiException. +type APIException struct { + Message string `json:"message,omitempty"` + StatusCode int32 `json:"statusCode,omitempty"` + Type string `json:"type,omitempty"` +} + +func (a APIException) Error() string { + return fmt.Sprintf("%d: %s: %s", a.StatusCode, a.Type, a.Message) +} + +// APIResponse defines model for apiResponse. +type APIResponse struct { + Exception *APIException `json:"exception,omitempty"` + StatusCode int32 `json:"statusCode,omitempty"` +} + +// DNSRecord defines model for dnsRecords. +type DNSRecord struct { + ID int64 `json:"id,omitempty"` + Type string `json:"recordType,omitempty"` + DomainID int64 `json:"domainId,omitempty"` + DomainName string `json:"domainName,omitempty"` + NodeName string `json:"nodeName,omitempty"` + Hostname string `json:"hostname,omitempty"` + State bool `json:"state,omitempty"` + Content string `json:"content,omitempty"` + TextData string `json:"textData,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +// DNSHostname defines model for DNS.hostname. +type DNSHostname struct { + *APIException + + ID int64 `json:"id,omitempty"` + DomainName string `json:"domainName,omitempty"` + Hostname string `json:"hostname,omitempty"` + Node string `json:"node,omitempty"` +} + +// RecordsResponse defines model for recordsResponse. +type RecordsResponse struct { + *APIException + + DNSRecords []DNSRecord `json:"dnsRecords,omitempty"` +} + +// RecordResponse defines model for recordResponse. +type RecordResponse struct { + *APIException + + DNSRecord +}