From 078f84ed34d52ddc871cebfec35af8ba3d0ecdb9 Mon Sep 17 00:00:00 2001 From: Eirik Rye Date: Mon, 8 Feb 2021 20:13:21 +0100 Subject: [PATCH] Add Domeneshop DNS01 provider (#1345) --- README.md | 30 +-- cmd/zz_gen_cmd_dnshelp.go | 21 ++ docs/content/dns/zz_gen_domeneshop.md | 68 +++++++ providers/dns/dns_providers.go | 3 + providers/dns/domeneshop/domeneshop.go | 149 ++++++++++++++ providers/dns/domeneshop/domeneshop.toml | 31 +++ providers/dns/domeneshop/domeneshop_test.go | 153 ++++++++++++++ providers/dns/domeneshop/internal/client.go | 135 +++++++++++++ .../dns/domeneshop/internal/client_test.go | 187 ++++++++++++++++++ providers/dns/domeneshop/internal/types.go | 30 +++ 10 files changed, 792 insertions(+), 15 deletions(-) create mode 100644 docs/content/dns/zz_gen_domeneshop.md create mode 100644 providers/dns/domeneshop/domeneshop.go create mode 100644 providers/dns/domeneshop/domeneshop.toml create mode 100644 providers/dns/domeneshop/domeneshop_test.go create mode 100644 providers/dns/domeneshop/internal/client.go create mode 100644 providers/dns/domeneshop/internal/client_test.go create mode 100644 providers/dns/domeneshop/internal/types.go diff --git a/README.md b/README.md index 5c6cf1f1..c0fc1396 100644 --- a/README.md +++ b/README.md @@ -52,20 +52,20 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [deSEC.io](https://go-acme.github.io/lego/dns/desec/) | [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/) | [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/) | -| [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | -| [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | -| [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | -| [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | -| [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [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/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [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/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | -| [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | +| [Domeneshop](https://go-acme.github.io/lego/dns/domeneshop/) | [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/) | +| [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/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | +| [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | +| [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | +| [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | +| [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [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/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [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/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | +| [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 e812540c..d9da39a6 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -36,6 +36,7 @@ func allDNSCodes() string { "dnsmadeeasy", "dnspod", "dode", + "domeneshop", "dreamhost", "duckdns", "dyn", @@ -579,6 +580,26 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dode`) + case "domeneshop": + // generated from: providers/dns/domeneshop/domeneshop.toml + ew.writeln(`Configuration for Domeneshop.`) + ew.writeln(`Code: 'domeneshop'`) + ew.writeln(`Since: 'v4.3.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "DOMENESHOP_API_PASSWORD": API secret`) + ew.writeln(` - "DOMENESHOP_API_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "DOMENESHOP_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "DOMENESHOP_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "DOMENESHOP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/domeneshop`) + case "dreamhost": // generated from: providers/dns/dreamhost/dreamhost.toml ew.writeln(`Configuration for DreamHost.`) diff --git a/docs/content/dns/zz_gen_domeneshop.md b/docs/content/dns/zz_gen_domeneshop.md new file mode 100644 index 00000000..223ee3a4 --- /dev/null +++ b/docs/content/dns/zz_gen_domeneshop.md @@ -0,0 +1,68 @@ +--- +title: "Domeneshop" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: domeneshop +--- + + + + + +Since: v4.3.0 + +Configuration for [Domeneshop](https://domene.shop). + + + + +- Code: `domeneshop` + +Here is an example bash command using the Domeneshop provider: + +```bash +DOMENESHOP_API_TOKEN= \ +DOMENESHOP_API_SECRET= \ +lego --email example@example.com --dns domeneshop --domains example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `DOMENESHOP_API_PASSWORD` | API secret | +| `DOMENESHOP_API_TOKEN` | API token | + +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 | +|--------------------------------|-------------| +| `DOMENESHOP_HTTP_TIMEOUT` | API request timeout | +| `DOMENESHOP_POLLING_INTERVAL` | Time between DNS propagation check | +| `DOMENESHOP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | + +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). + +### API credentials + +Visit the following page for information on how to create API credentials with Domeneshop: + + https://api.domeneshop.no/docs/#section/Authentication + + + +## More information + +- [API documentation](https://api.domeneshop.no/docs) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 41c3404d..c858f721 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -27,6 +27,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy" "github.com/go-acme/lego/v4/providers/dns/dnspod" "github.com/go-acme/lego/v4/providers/dns/dode" + "github.com/go-acme/lego/v4/providers/dns/domeneshop" "github.com/go-acme/lego/v4/providers/dns/dreamhost" "github.com/go-acme/lego/v4/providers/dns/duckdns" "github.com/go-acme/lego/v4/providers/dns/dyn" @@ -134,6 +135,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return dnspod.NewDNSProvider() case "dode": return dode.NewDNSProvider() + case "domeneshop", "domainnameshop": + return domeneshop.NewDNSProvider() case "dreamhost": return dreamhost.NewDNSProvider() case "duckdns": diff --git a/providers/dns/domeneshop/domeneshop.go b/providers/dns/domeneshop/domeneshop.go new file mode 100644 index 00000000..d7c4b309 --- /dev/null +++ b/providers/dns/domeneshop/domeneshop.go @@ -0,0 +1,149 @@ +// Package domeneshop implements a DNS provider for solving the DNS-01 challenge using domeneshop DNS. +package domeneshop + +import ( + "errors" + "fmt" + "net/http" + "strings" + "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/domeneshop/internal" +) + +// Environment variables names. +const ( + envNamespace = "DOMENESHOP_" + + EnvAPIToken = envNamespace + "API_TOKEN" + EnvAPISecret = envNamespace + "API_SECRET" + + 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 { + APIToken string + APISecret string + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 20*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for domeneshop. +// Credentials must be passed in the environment variables: +// DOMENESHOP_API_TOKEN, DOMENESHOP_API_SECRET. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken, EnvAPISecret) + if err != nil { + return nil, fmt.Errorf("domeneshop: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + config.APISecret = values[EnvAPISecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Domeneshop. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("domeneshop: the configuration of the DNS provider is nil") + } + + if config.APIToken == "" || config.APISecret == "" { + return nil, errors.New("domeneshop: credentials missing") + } + + client := internal.NewClient(config.APIToken, config.APISecret) + + if config.HTTPClient != nil { + client.HTTPClient = 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, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + zone, host, err := d.splitDomain(fqdn) + if err != nil { + return fmt.Errorf("domeneshop: %w", err) + } + + domainInstance, err := d.client.GetDomainByName(zone) + if err != nil { + return fmt.Errorf("domeneshop: %w", err) + } + + err = d.client.CreateTXTRecord(domainInstance, host, value) + if err != nil { + return fmt.Errorf("domeneshop: failed to create record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + zone, host, err := d.splitDomain(fqdn) + if err != nil { + return fmt.Errorf("domeneshop: %w", err) + } + + domainInstance, err := d.client.GetDomainByName(zone) + if err != nil { + return fmt.Errorf("domeneshop: %w", err) + } + + if err := d.client.DeleteTXTRecord(domainInstance, host, value); err != nil { + return fmt.Errorf("domeneshop: failed to create record: %w", err) + } + + return nil +} + +// splitDomain splits the hostname from the authoritative zone, and returns both parts (non-fqdn). +func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) { + zone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", "", err + } + + host := dns01.UnFqdn(strings.TrimSuffix(fqdn, zone)) + zone = dns01.UnFqdn(zone) + + return zone, host, nil +} diff --git a/providers/dns/domeneshop/domeneshop.toml b/providers/dns/domeneshop/domeneshop.toml new file mode 100644 index 00000000..3e283123 --- /dev/null +++ b/providers/dns/domeneshop/domeneshop.toml @@ -0,0 +1,31 @@ +Name = "Domeneshop" +Description = '''''' +URL = "https://domene.shop" +Code = "domeneshop" +Since = "v4.3.0" + +Example = ''' +DOMENESHOP_API_TOKEN= \ +DOMENESHOP_API_SECRET= \ +lego --email example@example.com --dns domeneshop --domains example.com run +''' + +Additional = ''' +### API credentials + +Visit the following page for information on how to create API credentials with Domeneshop: + + https://api.domeneshop.no/docs/#section/Authentication +''' + +[Configuration] + [Configuration.Credentials] + DOMENESHOP_API_TOKEN = "API token" + DOMENESHOP_API_PASSWORD = "API secret" + [Configuration.Additional] + DOMENESHOP_POLLING_INTERVAL = "Time between DNS propagation check" + DOMENESHOP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + DOMENESHOP_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://api.domeneshop.no/docs" diff --git a/providers/dns/domeneshop/domeneshop_test.go b/providers/dns/domeneshop/domeneshop_test.go new file mode 100644 index 00000000..ffb55200 --- /dev/null +++ b/providers/dns/domeneshop/domeneshop_test.go @@ -0,0 +1,153 @@ +package domeneshop + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAPIToken, + EnvAPISecret). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "A", + EnvAPISecret: "B", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIToken: "", + EnvAPISecret: "", + }, + expected: "domeneshop: some credentials information are missing: DOMENESHOP_API_TOKEN,DOMENESHOP_API_SECRET", + }, + { + desc: "missing api token", + envVars: map[string]string{ + EnvAPIToken: "", + EnvAPISecret: "A", + }, + expected: "domeneshop: some credentials information are missing: DOMENESHOP_API_TOKEN", + }, + { + desc: "missing api secret", + envVars: map[string]string{ + EnvAPIToken: "A", + EnvAPISecret: "", + }, + expected: "domeneshop: some credentials information are missing: DOMENESHOP_API_SECRET", + }, + } + + 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 + apiSecret string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "A", + apiSecret: "B", + }, + { + desc: "missing credentials", + expected: "domeneshop: credentials missing", + }, + { + desc: "missing api token", + apiToken: "", + apiSecret: "B", + expected: "domeneshop: credentials missing", + }, + { + desc: "missing api secret", + apiToken: "A", + apiSecret: "", + expected: "domeneshop: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + + config.APIToken = test.apiToken + config.APISecret = test.apiSecret + + 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/domeneshop/internal/client.go b/providers/dns/domeneshop/internal/client.go new file mode 100644 index 00000000..0aca84c3 --- /dev/null +++ b/providers/dns/domeneshop/internal/client.go @@ -0,0 +1,135 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +const defaultBaseURL string = "https://api.domeneshop.no/v0" + +// Client implements a very simple wrapper around the Domeneshop API. +// For now it will only deal with adding and removing TXT records, as required by ACME providers. +// https://api.domeneshop.no/docs/ +type Client struct { + HTTPClient *http.Client + baseURL string + apiToken string + apiSecret string +} + +// NewClient returns an instance of the Domeneshop API wrapper. +func NewClient(apiToken, apiSecret string) *Client { + return &Client{ + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + baseURL: defaultBaseURL, + apiToken: apiToken, + apiSecret: apiSecret, + } +} + +// GetDomainByName fetches the domain list and returns the Domain object for the matching domain. +// https://api.domeneshop.no/docs/#operation/getDomains +func (c *Client) GetDomainByName(domain string) (*Domain, error) { + var domains []Domain + + err := c.doRequest(http.MethodGet, "domains", nil, &domains) + if err != nil { + return nil, err + } + + for _, d := range domains { + if !d.Services.DNS { + // Domains without DNS service cannot have DNS record added. + continue + } + + if d.Name == domain { + return &d, nil + } + } + + return nil, fmt.Errorf("failed to find matching domain name: %s", domain) +} + +// CreateTXTRecord creates a TXT record with the provided host (subdomain) and data. +// https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns/post +func (c *Client) CreateTXTRecord(domain *Domain, host string, data string) error { + jsonRecord, err := json.Marshal(DNSRecord{ + Data: data, + Host: host, + TTL: 300, + Type: "TXT", + }) + if err != nil { + return err + } + + return c.doRequest(http.MethodPost, fmt.Sprintf("domains/%d/dns", domain.ID), jsonRecord, nil) +} + +// DeleteTXTRecord deletes the DNS record matching the provided host and data. +// https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns~1{recordId}/delete +func (c *Client) DeleteTXTRecord(domain *Domain, host string, data string) error { + record, err := c.getDNSRecordByHostData(*domain, host, data) + if err != nil { + return err + } + + return c.doRequest(http.MethodDelete, fmt.Sprintf("domains/%d/dns/%d", domain.ID, record.ID), nil, nil) +} + +// getDNSRecordByHostData finds the first matching DNS record with the provided host and data. +// https://api.domeneshop.no/docs/#operation/getDnsRecords +func (c *Client) getDNSRecordByHostData(domain Domain, host string, data string) (*DNSRecord, error) { + var records []DNSRecord + + err := c.doRequest(http.MethodGet, fmt.Sprintf("domains/%d/dns", domain.ID), nil, &records) + if err != nil { + return nil, err + } + + for _, r := range records { + if r.Host == host && r.Data == data { + return &r, nil + } + } + + return nil, fmt.Errorf("failed to find record with host %s for domain %s", host, domain.Name) +} + +// doRequest makes a request against the API with an optional body, +// and makes sure that the required Authorization header is set using `setBasicAuth`. +func (c *Client) doRequest(method string, endpoint string, reqBody []byte, v interface{}) error { + req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", c.baseURL, endpoint), bytes.NewBuffer(reqBody)) + if err != nil { + return err + } + + req.SetBasicAuth(c.apiToken, c.apiSecret) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= http.StatusBadRequest { + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + return fmt.Errorf("API returned %s: %s", resp.Status, respBody) + } + + if v != nil { + return json.NewDecoder(resp.Body).Decode(&v) + } + + return nil +} diff --git a/providers/dns/domeneshop/internal/client_test.go b/providers/dns/domeneshop/internal/client_test.go new file mode 100644 index 00000000..3d709b57 --- /dev/null +++ b/providers/dns/domeneshop/internal/client_test.go @@ -0,0 +1,187 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T) (*Client, *http.ServeMux) { + t.Helper() + + mux := http.NewServeMux() + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client := NewClient("token", "secret") + + client.baseURL = server.URL + + return client, mux +} + +func TestClient_CreateTXTRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Basic dG9rZW46c2VjcmV0" { + http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized) + } + + _, _ = rw.Write([]byte(`{"id": 1}`)) + }) + + err := client.CreateTXTRecord(&Domain{ID: 1}, "example", "txtTXTtxt") + require.NoError(t, err) +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Basic dG9rZW46c2VjcmV0" { + http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized) + } + + _, _ = rw.Write([]byte(`[ + { + "id": 1, + "host": "example.com", + "ttl": 3600, + "type": "TXT", + "data": "txtTXTtxt" + } +]`)) + }) + + mux.HandleFunc("/domains/1/dns/1", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Basic dG9rZW46c2VjcmV0" { + http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized) + } + }) + + err := client.DeleteTXTRecord(&Domain{ID: 1}, "example.com", "txtTXTtxt") + require.NoError(t, err) +} + +func TestClient_getDNSRecordByHostData(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Basic dG9rZW46c2VjcmV0" { + http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized) + } + + _, _ = rw.Write([]byte(`[ + { + "id": 1, + "host": "example.com", + "ttl": 3600, + "type": "TXT", + "data": "txtTXTtxt" + } +]`)) + }) + + record, err := client.getDNSRecordByHostData(Domain{ID: 1}, "example.com", "txtTXTtxt") + require.NoError(t, err) + + expected := &DNSRecord{ + ID: 1, + Type: "TXT", + Host: "example.com", + Data: "txtTXTtxt", + TTL: 3600, + } + + assert.Equal(t, expected, record) +} + +func TestClient_GetDomainByName(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/domains", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Basic dG9rZW46c2VjcmV0" { + http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized) + } + + _, _ = rw.Write([]byte(`[ + { + "id": 1, + "domain": "example.com", + "expiry_date": "2019-08-24", + "registered_date": "2019-08-24", + "renew": true, + "registrant": "Ola Nordmann", + "status": "active", + "nameservers": [ + "ns1.hyp.net", + "ns2.hyp.net", + "ns3.hyp.net" + ], + "services": { + "registrar": true, + "dns": true, + "email": true, + "webhotel": "none" + } + } +]`)) + }) + + domain, err := client.GetDomainByName("example.com") + require.NoError(t, err) + + expected := &Domain{ + Name: "example.com", + ID: 1, + ExpiryDate: "2019-08-24", + Nameservers: []string{"ns1.hyp.net", "ns2.hyp.net", "ns3.hyp.net"}, + RegisteredDate: "2019-08-24", + Registrant: "Ola Nordmann", + Renew: true, + Services: Service{ + DNS: true, + Email: true, + Registrar: true, + Webhotel: "none", + }, + Status: "active", + } + + assert.Equal(t, expected, domain) +} diff --git a/providers/dns/domeneshop/internal/types.go b/providers/dns/domeneshop/internal/types.go new file mode 100644 index 00000000..20ab4e67 --- /dev/null +++ b/providers/dns/domeneshop/internal/types.go @@ -0,0 +1,30 @@ +package internal + +// Domain JSON data structure. +type Domain struct { + Name string `json:"domain"` + ID int `json:"id"` + ExpiryDate string `json:"expiry_date"` + Nameservers []string `json:"nameservers"` + RegisteredDate string `json:"registered_date"` + Registrant string `json:"registrant"` + Renew bool `json:"renew"` + Services Service `json:"services"` + Status string +} + +type Service struct { + DNS bool `json:"dns"` + Email bool `json:"email"` + Registrar bool `json:"registrar"` + Webhotel string `json:"webhotel"` +} + +// DNSRecord JSON data structure. +type DNSRecord struct { + Data string `json:"data"` + Host string `json:"host"` + ID int `json:"id"` + TTL int `json:"ttl"` + Type string `json:"type"` +}