Repository: go-acme/lego
Pull request #870 (2019-04-27 16:18:28)

Add support for Joker.com DMAPI
Implementation for DNS API https://joker.com/faq/category/39/22-dmapi.html


ldez: Hello, in order for a PR adding a DNS provider to be accepted, you have to:

- [ ] add a description to your PR
- [ ] be able to maintain this provider
- [ ] have a homogeneous design with the other providers
- [ ] add tests ("live" and units)
https://github.com/go-acme/lego/blob/c938de68f262c057542d44bcb67ef961cefcc60e/providers/dns/cloudflare/cloudflare_test.go#L125-L151

```shell
make test
```
- [ ] add the provider to the [readme.md](https://github.com/go-acme/lego#dns-providers)
- [ ] add a [provider descriptor](https://github.com/go-acme/lego/blob/master/providers/dns/cloudflare/cloudflare.toml)
- [ ] generate CLI help and documentation
```bash
make generate-dns
```
- [ ] be able to do: _(and put the output of this command to a comment in your PR)_
```bash
rm -rf ./lego

./lego -m your@email.com --dns YOUR_PROVIDER_NAME -d *.example.com -d example.com -s https://acme-staging-v02.api.letsencrypt.org/directory run
```
Note the wildcard domain is important.
- [ ] pass the linter ([golangci-lint](https://github.com/golangci/golangci-lint#install) must be installed):
```shell
make checks
```
kad: I'll be able to maintain that DNS provider, as it is used for my domains.

Design is similar to other providers (e.g. namecheap, exec and httpreq were used as examples)

Code successfully supports wildcard domains

```shell
$ dist/lego -a -m kad@kad.name --dns joker -d '*.afield.eu' -d afield.eu -s https://acme-staging-v02.api.letsencrypt.org/directory run
2019/04/28 00:34:47 No key found for account kad@kad.name. Generating a P384 key.
2019/04/28 00:34:47 Saved key to /Users/kad/go/src/github.com/go-acme/lego/.lego/accounts/acme-staging-v02.api.letsencrypt.org/kad@kad.name/keys/kad@kad.name.key
2019/04/28 00:34:47 [INFO] acme: Registering account for kad@kad.name
!!!! HEADS UP !!!!

		Your account credentials have been saved in your Let's Encrypt
		configuration directory at "/Users/kad/go/src/github.com/go-acme/lego/.lego/accounts".
		You should make a secure backup	of this folder now. This
		configuration directory will also contain certificates and
		private keys obtained from Let's Encrypt so making regular
		backups of this folder is ideal.2019/04/28 00:34:48 [INFO] [*.afield.eu, afield.eu] acme: Obtaining bundled SAN certificate
2019/04/28 00:34:49 [INFO] [*.afield.eu] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/cZTGJepUs_f_qa5f0XYLkD1XGeKJX6rvI7iJ56wzIc0
2019/04/28 00:34:49 [INFO] [afield.eu] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/KO7yMc4ZcLWfKKOrc_EsA9Et5-eLjdYY9DeS_PLdHFg
2019/04/28 00:34:49 [INFO] [*.afield.eu] acme: use dns-01 solver
2019/04/28 00:34:49 [INFO] [afield.eu] acme: Could not find solver for: tls-alpn-01
2019/04/28 00:34:49 [INFO] [afield.eu] acme: Could not find solver for: http-01
2019/04/28 00:34:49 [INFO] [afield.eu] acme: use dns-01 solver
2019/04/28 00:34:49 [INFO] [*.afield.eu] acme: Preparing to solve DNS-01
2019/04/28 00:34:54 [INFO] [afield.eu] acme: Preparing to solve DNS-01
2019/04/28 00:34:58 [INFO] [*.afield.eu] acme: Trying to solve DNS-01
2019/04/28 00:34:58 [INFO] [*.afield.eu] acme: Checking DNS record propagation using [10.0.0.1:53]
2019/04/28 00:34:58 [INFO] Wait for propagation [timeout: 1h0m0s, interval: 15s]
2019/04/28 00:34:58 [INFO] [*.afield.eu] acme: Waiting for DNS record propagation.
2019/04/28 00:35:20 [INFO] [*.afield.eu] The server validated our request
2019/04/28 00:35:20 [INFO] [afield.eu] acme: Trying to solve DNS-01
2019/04/28 00:35:20 [INFO] [afield.eu] acme: Checking DNS record propagation using [10.0.0.1:53]
2019/04/28 00:35:20 [INFO] Wait for propagation [timeout: 1h0m0s, interval: 15s]
2019/04/28 00:35:21 [INFO] [afield.eu] The server validated our request
2019/04/28 00:35:21 [INFO] [*.afield.eu] acme: Cleaning DNS-01 challenge
2019/04/28 00:35:26 [INFO] [afield.eu] acme: Cleaning DNS-01 challenge
2019/04/28 00:35:27 [INFO] [*.afield.eu, afield.eu] acme: Validations succeeded; requesting certificates
2019/04/28 00:35:28 [INFO] [*.afield.eu] Server responded with a certificate.
$
```

I'll add unit / live tests.
ldez: I saw that you were inspired by the Namecheap provider, you are so inspired of Namecheap that Namecheap is written in all the comments :smile: 

kad: Ouch. I forgot to clean-up comments :)
Updated, now with tests.
ldez: Could you add tests on `parseJokerResponse`?

Could you also address review comments in terms of additional commits (and don't amend/squash existing ones).

Patch:
diff --git a/README.md b/README.md
index e14c4ac1..aee6eb0b 100644
--- a/README.md
+++ b/README.md
@@ -51,9 +51,10 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
 | [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/)                                |
-| [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/)                     | [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/)                      | [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/)                      | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/)                          |
-| [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/)                 | [Selectel](https://go-acme.github.io/lego/dns/selectel/)                        | [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/)                          | [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/)                           |
+| [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/)                     |
+| [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/)                      |
+| [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/)                      |
+| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/)                          | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/)                 | [Selectel](https://go-acme.github.io/lego/dns/selectel/)                        | [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/)                          | [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 474cc837..ea9aadc3 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -47,6 +47,7 @@ func allDNSCodes() string {
 		"httpreq",
 		"iij",
 		"inwx",
+		"joker",
 		"lightsail",
 		"linode",
 		"linodev4",
@@ -724,6 +725,26 @@ func displayDNSHelp(name string) {
 		fmt.Fprintln(w)
 		fmt.Fprintln(w, `More information: https://go-acme.github.io/lego/dns/inwx`)
 
+	case "joker":
+		// generated from: providers/dns/joker/joker.toml
+		fmt.Fprintln(w, `Configuration for Joker.`)
+		fmt.Fprintln(w, `Code: 'joker'`)
+		fmt.Fprintln(w, `Since: 'v2.6.0'`)
+		fmt.Fprintln(w)
+
+		fmt.Fprintln(w, `Credentials:`)
+		fmt.Fprintln(w, `	- "JOKER_API_KEY": API key`)
+		fmt.Fprintln(w)
+
+		fmt.Fprintln(w, `Additional Configuration:`)
+		fmt.Fprintln(w, `	- "JOKER_HTTP_TIMEOUT": API request timeout`)
+		fmt.Fprintln(w, `	- "JOKER_POLLING_INTERVAL": Time between DNS propagation check`)
+		fmt.Fprintln(w, `	- "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+		fmt.Fprintln(w, `	- "JOKER_TTL": The TTL of the TXT record used for the DNS challenge`)
+
+		fmt.Fprintln(w)
+		fmt.Fprintln(w, `More information: https://go-acme.github.io/lego/dns/joker`)
+
 	case "lightsail":
 		// generated from: providers/dns/lightsail/lightsail.toml
 		fmt.Fprintln(w, `Configuration for Amazon Lightsail.`)
diff --git a/docs/content/dns/zz_gen_joker.md b/docs/content/dns/zz_gen_joker.md
new file mode 100644
index 00000000..f41ffb5a
--- /dev/null
+++ b/docs/content/dns/zz_gen_joker.md
@@ -0,0 +1,62 @@
+---
+title: "Joker"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: joker
+---
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/joker/joker.toml -->
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+
+Since: v2.6.0
+
+Configuration for [Joker](https://joker.com).
+
+
+<!--more-->
+
+- Code: `joker`
+
+Here is an example bash command using the Joker provider:
+
+```bash
+JOKER_API_KEY=<your API key> \
+lego --dns joker --domains my.domain.com --email my@email.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `JOKER_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 |
+|--------------------------------|-------------|
+| `JOKER_HTTP_TIMEOUT` | API request timeout |
+| `JOKER_POLLING_INTERVAL` | Time between DNS propagation check |
+| `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `JOKER_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://joker.com/faq/category/39/22-dmapi.html)
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/joker/joker.toml -->
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index a661d17c..e9a4c6d2 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -36,6 +36,7 @@ import (
 	"github.com/go-acme/lego/providers/dns/httpreq"
 	"github.com/go-acme/lego/providers/dns/iij"
 	"github.com/go-acme/lego/providers/dns/inwx"
+	"github.com/go-acme/lego/providers/dns/joker"
 	"github.com/go-acme/lego/providers/dns/lightsail"
 	"github.com/go-acme/lego/providers/dns/linode"
 	"github.com/go-acme/lego/providers/dns/linodev4"
@@ -127,6 +128,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
 		return iij.NewDNSProvider()
 	case "inwx":
 		return inwx.NewDNSProvider()
+	case "joker":
+		return joker.NewDNSProvider()
 	case "lightsail":
 		return lightsail.NewDNSProvider()
 	case "linode":
diff --git a/providers/dns/joker/client.go b/providers/dns/joker/client.go
new file mode 100644
index 00000000..18265a85
--- /dev/null
+++ b/providers/dns/joker/client.go
@@ -0,0 +1,197 @@
+package joker
+
+import (
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"github.com/go-acme/lego/challenge/dns01"
+	"github.com/go-acme/lego/log"
+)
+
+const defaultBaseURL = "https://dmapi.joker.com/request/"
+
+// Joker DMAPI Response
+type response struct {
+	Headers    url.Values
+	Body       string
+	StatusCode int
+	StatusText string
+	AuthSid    string
+}
+
+// parseResponse parses HTTP response body
+func parseResponse(message string) *response {
+	r := &response{Headers: url.Values{}, StatusCode: -1}
+
+	parts := strings.SplitN(message, "\n\n", 2)
+
+	for _, line := range strings.Split(parts[0], "\n") {
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+
+		kv := strings.SplitN(line, ":", 2)
+
+		val := ""
+		if len(kv) == 2 {
+			val = strings.TrimSpace(kv[1])
+		}
+
+		r.Headers.Add(kv[0], val)
+
+		switch kv[0] {
+		case "Status-Code":
+			i, err := strconv.Atoi(val)
+			if err == nil {
+				r.StatusCode = i
+			}
+		case "Status-Text":
+			r.StatusText = val
+		case "Auth-Sid":
+			r.AuthSid = val
+		}
+	}
+
+	if len(parts) > 1 {
+		r.Body = parts[1]
+	}
+
+	return r
+}
+
+// login performs a login to Joker's DMAPI
+func (d *DNSProvider) login() (*response, error) {
+	if d.config.AuthSid != "" {
+		// already logged in
+		return nil, nil
+	}
+
+	response, err := d.postRequest("login", url.Values{"api-key": {d.config.APIKey}})
+	if err != nil {
+		return response, err
+	}
+
+	if response == nil {
+		return nil, fmt.Errorf("login returned nil response")
+	}
+
+	if response.AuthSid == "" {
+		return response, fmt.Errorf("login did not return valid Auth-Sid")
+	}
+
+	d.config.AuthSid = response.AuthSid
+
+	return response, nil
+}
+
+// logout closes authenticated session with Joker's DMAPI
+func (d *DNSProvider) logout() (*response, error) {
+	if d.config.AuthSid == "" {
+		return nil, fmt.Errorf("already logged out")
+	}
+
+	response, err := d.postRequest("logout", url.Values{})
+	if err == nil {
+		d.config.AuthSid = ""
+	}
+	return response, err
+}
+
+// getZone returns content of DNS zone for domain
+func (d *DNSProvider) getZone(domain string) (*response, error) {
+	if d.config.AuthSid == "" {
+		return nil, fmt.Errorf("must be logged in to get zone")
+	}
+
+	return d.postRequest("dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}})
+}
+
+// putZone uploads DNS zone to Joker DMAPI
+func (d *DNSProvider) putZone(domain, zone string) (*response, error) {
+	if d.config.AuthSid == "" {
+		return nil, fmt.Errorf("must be logged in to put zone")
+	}
+
+	return d.postRequest("dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}})
+}
+
+// postRequest performs actual HTTP request
+func (d *DNSProvider) postRequest(cmd string, data url.Values) (*response, error) {
+	uri := d.config.BaseURL + cmd
+
+	if d.config.AuthSid != "" {
+		data.Set("auth-sid", d.config.AuthSid)
+	}
+
+	if d.config.Debug {
+		log.Infof("postRequest:\n\tURL: %q\n\tData: %v", uri, data)
+	}
+
+	resp, err := d.config.HTTPClient.PostForm(uri, data)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("HTTP error %d [%s]: %v", resp.StatusCode, http.StatusText(resp.StatusCode), string(body))
+	}
+
+	return parseResponse(string(body)), nil
+}
+
+// Temporary workaround, until it get fixed on API side
+func fixTxtLines(line string) string {
+	fields := strings.Fields(line)
+
+	if len(fields) < 6 || fields[1] != "TXT" {
+		return line
+	}
+
+	if fields[3][0] == '"' && fields[4] == `"` {
+		fields[3] = strings.TrimSpace(fields[3]) + `"`
+		fields = append(fields[:4], fields[5:]...)
+	}
+
+	return strings.Join(fields, " ")
+}
+
+// removeTxtEntryFromZone clean-ups all TXT records with given name
+func removeTxtEntryFromZone(zone, relative string) (string, bool) {
+	prefix := fmt.Sprintf("%s TXT 0 ", relative)
+
+	modified := false
+	var zoneEntries []string
+	for _, line := range strings.Split(zone, "\n") {
+		if strings.HasPrefix(line, prefix) {
+			modified = true
+			continue
+		}
+		zoneEntries = append(zoneEntries, line)
+	}
+
+	return strings.TrimSpace(strings.Join(zoneEntries, "\n")), modified
+}
+
+// addTxtEntryToZone returns DNS zone with added TXT record
+func addTxtEntryToZone(zone, relative, value string, ttl int) string {
+	var zoneEntries []string
+
+	for _, line := range strings.Split(zone, "\n") {
+		zoneEntries = append(zoneEntries, fixTxtLines(line))
+	}
+
+	newZoneEntry := fmt.Sprintf("%s TXT 0 %q %d", relative, value, ttl)
+	zoneEntries = append(zoneEntries, newZoneEntry)
+
+	return strings.TrimSpace(strings.Join(zoneEntries, "\n"))
+}
diff --git a/providers/dns/joker/joker.go b/providers/dns/joker/joker.go
new file mode 100644
index 00000000..d2dc1cac
--- /dev/null
+++ b/providers/dns/joker/joker.go
@@ -0,0 +1,174 @@
+// Package joker implements a DNS provider for solving the DNS-01 challenge using joker.com DMAPI.
+package joker
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/challenge/dns01"
+	"github.com/go-acme/lego/log"
+	"github.com/go-acme/lego/platform/config/env"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+	Debug              bool
+	BaseURL            string
+	APIKey             string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	TTL                int
+	HTTPClient         *http.Client
+	AuthSid            string
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider
+func NewDefaultConfig() *Config {
+	return &Config{
+		BaseURL:            defaultBaseURL,
+		Debug:              env.GetOrDefaultBool("JOKER_DEBUG", false),
+		TTL:                env.GetOrDefaultInt("JOKER_TTL", dns01.DefaultTTL),
+		PropagationTimeout: env.GetOrDefaultSecond("JOKER_PROPAGATION_TIMEOUT", dns01.DefaultPropagationTimeout),
+		PollingInterval:    env.GetOrDefaultSecond("JOKER_POLLING_INTERVAL", dns01.DefaultPollingInterval),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond("JOKER_HTTP_TIMEOUT", 60*time.Second),
+		},
+	}
+}
+
+// DNSProvider is an implementation of the ChallengeProviderTimeout interface
+// that uses Joker's DMAPI to manage TXT records for a domain.
+type DNSProvider struct {
+	config *Config
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Joker DMAPI.
+// Credentials must be passed in the environment variable JOKER_API_KEY.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get("JOKER_API_KEY")
+	if err != nil {
+		return nil, fmt.Errorf("joker: %v", err)
+	}
+
+	config := NewDefaultConfig()
+	config.APIKey = values["JOKER_API_KEY"]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Joker DMAPI.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("joker: the configuration of the DNS provider is nil")
+	}
+
+	if config.APIKey == "" {
+		return nil, fmt.Errorf("joker: credentials missing")
+	}
+
+	if !strings.HasSuffix(config.BaseURL, "/") {
+		config.BaseURL += "/"
+	}
+
+	return &DNSProvider{config: config}, nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+// Present installs a TXT record for the DNS challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value := dns01.GetRecord(domain, keyAuth)
+
+	zone, err := dns01.FindZoneByFqdn(fqdn)
+	if err != nil {
+		return fmt.Errorf("joker: %v", err)
+	}
+
+	relative := getRelative(fqdn, zone)
+
+	if d.config.Debug {
+		log.Infof("[%s] joker: adding TXT record %q to zone %q with value %q", domain, relative, zone, value)
+	}
+
+	response, err := d.login()
+	if err != nil {
+		return formatResponseError(response, err)
+	}
+
+	response, err = d.getZone(zone)
+	if err != nil || response.StatusCode != 0 {
+		return formatResponseError(response, err)
+	}
+
+	dnsZone := addTxtEntryToZone(response.Body, relative, value, d.config.TTL)
+
+	response, err = d.putZone(zone, dnsZone)
+	if err != nil || response.StatusCode != 0 {
+		return formatResponseError(response, err)
+	}
+
+	return nil
+}
+
+// CleanUp removes a TXT record used for a previous DNS challenge.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	fqdn, _ := dns01.GetRecord(domain, keyAuth)
+
+	zone, err := dns01.FindZoneByFqdn(fqdn)
+	if err != nil {
+		return fmt.Errorf("joker: %v", err)
+	}
+
+	relative := getRelative(fqdn, zone)
+
+	if d.config.Debug {
+		log.Infof("[%s] joker: removing entry %q from zone %q", domain, relative, zone)
+	}
+
+	response, err := d.login()
+	if err != nil {
+		return formatResponseError(response, err)
+	}
+
+	defer func() {
+		// Try to logout in case of errors
+		_, _ = d.logout()
+	}()
+
+	response, err = d.getZone(zone)
+	if err != nil || response.StatusCode != 0 {
+		return formatResponseError(response, err)
+	}
+
+	dnsZone, modified := removeTxtEntryFromZone(response.Body, relative)
+	if modified {
+		response, err = d.putZone(zone, dnsZone)
+		if err != nil || response.StatusCode != 0 {
+			return formatResponseError(response, err)
+		}
+	}
+
+	response, err = d.logout()
+	if err != nil {
+		return formatResponseError(response, err)
+	}
+	return nil
+}
+
+func getRelative(fqdn, zone string) string {
+	return dns01.UnFqdn(strings.TrimSuffix(fqdn, dns01.ToFqdn(zone)))
+}
+
+// formatResponseError formats error with optional details from DMAPI response
+func formatResponseError(response *response, err error) error {
+	if response != nil {
+		return fmt.Errorf("joker: DMAPI error: %v Response: %v", err, response.Headers)
+	}
+	return fmt.Errorf("joker: DMAPI error: %v", err)
+}
diff --git a/providers/dns/joker/joker.toml b/providers/dns/joker/joker.toml
new file mode 100644
index 00000000..0cdace4d
--- /dev/null
+++ b/providers/dns/joker/joker.toml
@@ -0,0 +1,22 @@
+Name = "Joker"
+Description = ''''''
+URL = "https://joker.com"
+Code = "joker"
+Since = "v2.6.0"
+
+Example = '''
+JOKER_API_KEY=<your API key> \
+lego --dns joker --domains my.domain.com --email my@email.com run
+'''
+
+[Configuration]
+  [Configuration.Credentials]
+    JOKER_API_KEY = "API key"
+  [Configuration.Additional]
+    JOKER_POLLING_INTERVAL = "Time between DNS propagation check"
+    JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
+    JOKER_TTL = "The TTL of the TXT record used for the DNS challenge"
+    JOKER_HTTP_TIMEOUT = "API request timeout"
+
+[Links]
+  API = "https://joker.com/faq/category/39/22-dmapi.html"


Test patch:
diff --git a/providers/dns/joker/client_test.go b/providers/dns/joker/client_test.go
new file mode 100644
index 00000000..e0ead7b5
--- /dev/null
+++ b/providers/dns/joker/client_test.go
@@ -0,0 +1,447 @@
+package joker
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	correctAuth     = "123"
+	incorrectAuth   = "321"
+	serverErrorAuth = "500"
+)
+
+func setup() (*http.ServeMux, *httptest.Server) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	return mux, server
+}
+
+func TestDNSProvider_login(t *testing.T) {
+	testCases := []struct {
+		desc               string
+		authKey            string
+		expectedError      bool
+		expectedStatusCode int
+		expectedAuthSid    string
+	}{
+		{
+			desc:               "correct key",
+			authKey:            correctAuth,
+			expectedStatusCode: 0,
+			expectedAuthSid:    correctAuth,
+		},
+		{
+			desc:               "incorrect key",
+			authKey:            incorrectAuth,
+			expectedStatusCode: 2200,
+			expectedError:      true,
+		},
+		{
+			desc:               "server error",
+			authKey:            serverErrorAuth,
+			expectedStatusCode: -500,
+			expectedError:      true,
+		},
+		{
+			desc:               "non-ok status code",
+			authKey:            "333",
+			expectedStatusCode: 2202,
+			expectedError:      true,
+		},
+	}
+
+	mux, server := setup()
+	defer server.Close()
+
+	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "POST", r.Method)
+
+		switch r.FormValue("api-key") {
+		case correctAuth:
+			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
+		case incorrectAuth:
+			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
+		case serverErrorAuth:
+			http.NotFound(w, r)
+		default:
+			_, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
+		}
+	})
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.BaseURL = server.URL
+			config.APIKey = test.authKey
+
+			p, err := NewDNSProviderConfig(config)
+			require.NoError(t, err)
+			require.NotNil(t, p)
+
+			response, err := p.login()
+			if test.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, response)
+				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
+				assert.Equal(t, test.expectedAuthSid, response.AuthSid)
+			}
+		})
+	}
+}
+
+func TestDNSProvider_logout(t *testing.T) {
+	testCases := []struct {
+		desc               string
+		authSid            string
+		expectedError      bool
+		expectedStatusCode int
+	}{
+		{
+			desc:               "correct auth-sid",
+			authSid:            correctAuth,
+			expectedStatusCode: 0,
+		},
+		{
+			desc:               "incorrect auth-sid",
+			authSid:            incorrectAuth,
+			expectedStatusCode: 2200,
+		},
+		{
+			desc:          "already logged out",
+			authSid:       "",
+			expectedError: true,
+		},
+		{
+			desc:          "server error",
+			authSid:       serverErrorAuth,
+			expectedError: true,
+		},
+	}
+
+	mux, server := setup()
+	defer server.Close()
+
+	mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "POST", r.Method)
+
+		switch r.FormValue("auth-sid") {
+		case correctAuth:
+			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n")
+		case incorrectAuth:
+			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
+		default:
+			http.NotFound(w, r)
+		}
+	})
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.BaseURL = server.URL
+			config.APIKey = "12345"
+			config.AuthSid = test.authSid
+
+			p, err := NewDNSProviderConfig(config)
+			require.NoError(t, err)
+			require.NotNil(t, p)
+
+			response, err := p.logout()
+			if test.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, response)
+				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
+			}
+		})
+	}
+}
+
+func TestDNSProvider_getZone(t *testing.T) {
+	var testZone = "@ A 0 192.0.2.2 3600"
+
+	testCases := []struct {
+		desc               string
+		authSid            string
+		domain             string
+		zone               string
+		expectedError      bool
+		expectedStatusCode int
+	}{
+		{
+			desc:               "correct auth-sid, known domain",
+			authSid:            correctAuth,
+			domain:             "known",
+			zone:               testZone,
+			expectedStatusCode: 0,
+		},
+		{
+			desc:               "incorrect auth-sid, known domain",
+			authSid:            incorrectAuth,
+			domain:             "known",
+			expectedStatusCode: 2202,
+		},
+		{
+			desc:               "correct auth-sid, unknown domain",
+			authSid:            correctAuth,
+			domain:             "unknown",
+			expectedStatusCode: 2202,
+		},
+		{
+			desc:          "server error",
+			authSid:       "500",
+			expectedError: true,
+		},
+	}
+
+	mux, server := setup()
+	defer server.Close()
+
+	mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "POST", r.Method)
+
+		authSid := r.FormValue("auth-sid")
+		domain := r.FormValue("domain")
+
+		switch {
+		case authSid == correctAuth && domain == "known":
+			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone)
+		case authSid == incorrectAuth || (authSid == correctAuth && domain == "unknown"):
+			_, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: Authorization error")
+		default:
+			http.NotFound(w, r)
+		}
+	})
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.BaseURL = server.URL
+			config.APIKey = "12345"
+			config.AuthSid = test.authSid
+
+			p, err := NewDNSProviderConfig(config)
+			require.NoError(t, err)
+			require.NotNil(t, p)
+
+			response, err := p.getZone(test.domain)
+			if test.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, response)
+				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
+				assert.Equal(t, test.zone, response.Body)
+			}
+		})
+	}
+}
+
+func Test_parseResponse(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		input    string
+		expected *response
+	}{
+		{
+			desc:  "Empty response",
+			input: "",
+			expected: &response{
+				Headers:    url.Values{},
+				StatusCode: -1,
+			},
+		},
+		{
+			desc:  "No headers, just body",
+			input: "\n\nTest body",
+			expected: &response{
+				Headers:    url.Values{},
+				Body:       "Test body",
+				StatusCode: -1,
+			},
+		},
+		{
+			desc:  "Headers and body",
+			input: "Test-Header: value\n\nTest body",
+			expected: &response{
+				Headers:    url.Values{"Test-Header": {"value"}},
+				Body:       "Test body",
+				StatusCode: -1,
+			},
+		},
+		{
+			desc:  "Headers and body + Auth-Sid",
+			input: "Test-Header: value\nAuth-Sid: 123\n\nTest body",
+			expected: &response{
+				Headers:    url.Values{"Test-Header": {"value"}, "Auth-Sid": {"123"}},
+				Body:       "Test body",
+				StatusCode: -1,
+				AuthSid:    "123",
+			},
+		},
+		{
+			desc:  "Headers and body + Status-Text",
+			input: "Test-Header: value\nStatus-Text: OK\n\nTest body",
+			expected: &response{
+				Headers:    url.Values{"Test-Header": {"value"}, "Status-Text": {"OK"}},
+				Body:       "Test body",
+				StatusText: "OK",
+				StatusCode: -1,
+			},
+		},
+		{
+			desc:  "Headers and body + Status-Code",
+			input: "Test-Header: value\nStatus-Code: 2020\n\nTest body",
+			expected: &response{
+				Headers:    url.Values{"Test-Header": {"value"}, "Status-Code": {"2020"}},
+				Body:       "Test body",
+				StatusCode: 2020,
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			response := parseResponse(test.input)
+
+			assert.Equal(t, test.expected, response)
+		})
+	}
+}
+
+func Test_removeTxtEntryFromZone(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		input    string
+		expected string
+		modified bool
+	}{
+		{
+			desc:     "empty zone",
+			input:    "",
+			expected: "",
+			modified: false,
+		},
+		{
+			desc:     "zone with only A entry",
+			input:    "@ A 0 192.0.2.2 3600",
+			expected: "@ A 0 192.0.2.2 3600",
+			modified: false,
+		},
+		{
+			desc:     "zone with only clenup entry",
+			input:    "_acme-challenge TXT 0  \"old \" 120",
+			expected: "",
+			modified: true,
+		},
+		{
+			desc:     "zone with one A and one cleanup entries",
+			input:    "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0  \"old \" 120",
+			expected: "@ A 0 192.0.2.2 3600",
+			modified: true,
+		},
+		{
+			desc:     "zone with one A and multiple cleanup entries",
+			input:    "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0  \"old \" 120\n_acme-challenge TXT 0  \"another \" 120",
+			expected: "@ A 0 192.0.2.2 3600",
+			modified: true,
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			zone, modified := removeTxtEntryFromZone(test.input, "_acme-challenge")
+			assert.Equal(t, zone, test.expected)
+			assert.Equal(t, modified, test.modified)
+		})
+	}
+}
+
+func Test_addTxtEntryToZone(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		input    string
+		expected string
+	}{
+		{
+			desc:     "empty zone",
+			input:    "",
+			expected: "_acme-challenge TXT 0 \"test\" 120",
+		},
+		{
+			desc:     "zone with A entry",
+			input:    "@ A 0 192.0.2.2 3600",
+			expected: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"test\" 120",
+		},
+		{
+			desc:     "zone with required cleanup entry",
+			input:    "_acme-challenge TXT 0  \"old \" 120",
+			expected: "_acme-challenge TXT 0 \"old\" 120\n_acme-challenge TXT 0 \"test\" 120",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+
+			zone := addTxtEntryToZone(test.input, "_acme-challenge", "test", 120)
+			assert.Equal(t, zone, test.expected)
+		})
+	}
+}
+
+func Test_fixTxtLines(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		input    string
+		expected string
+	}{
+		{
+			desc:     "clean-up",
+			input:    `_acme-challenge TXT 0  "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE " 120`,
+			expected: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`,
+		},
+		{
+			desc:     "already cleaned",
+			input:    `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`,
+			expected: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`,
+		},
+		{
+			desc:     "special DNS entry",
+			input:    "$dyndns=yes:username:password",
+			expected: "$dyndns=yes:username:password",
+		},
+		{
+			desc:     "SRV entry",
+			input:    "_jabber._tcp SRV 20/0 xmpp-server1.l.google.com:5269 300",
+			expected: "_jabber._tcp SRV 20/0 xmpp-server1.l.google.com:5269 300",
+		},
+		{
+			desc:     "MX entry",
+			input:    "@ MX 10 ASPMX.L.GOOGLE.COM 300",
+			expected: "@ MX 10 ASPMX.L.GOOGLE.COM 300",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+
+			line := fixTxtLines(test.input)
+			assert.Equal(t, line, test.expected)
+		})
+	}
+}
diff --git a/providers/dns/joker/joker_test.go b/providers/dns/joker/joker_test.go
new file mode 100644
index 00000000..427ac29d
--- /dev/null
+++ b/providers/dns/joker/joker_test.go
@@ -0,0 +1,135 @@
+package joker
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-acme/lego/platform/tester"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var envTest = tester.NewEnvTest("JOKER_API_KEY").WithDomain("JOKER_DOMAIN")
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				"JOKER_API_KEY": "123",
+			},
+		},
+		{
+			desc: "missing key",
+			envVars: map[string]string{
+				"JOKER_API_KEY": "",
+			},
+			expected: "joker: some credentials information are missing: JOKER_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)
+				assert.NotNil(t, p.config)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc            string
+		authKey         string
+		baseURL         string
+		expected        string
+		expectedBaseURL string
+	}{
+		{
+			desc:            "success",
+			authKey:         "123",
+			expectedBaseURL: defaultBaseURL,
+		},
+		{
+			desc:            "missing credentials",
+			expected:        "joker: credentials missing",
+			expectedBaseURL: defaultBaseURL,
+		},
+		{
+			desc:            "Base URL should ends with /",
+			authKey:         "123",
+			baseURL:         "http://example.com",
+			expectedBaseURL: "http://example.com/",
+		},
+		{
+			desc:            "Base URL already ends with /",
+			authKey:         "123",
+			baseURL:         "http://example.com/",
+			expectedBaseURL: "http://example.com/",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.APIKey = test.authKey
+			if test.baseURL != "" {
+				config.BaseURL = test.baseURL
+			}
+
+			p, err := NewDNSProviderConfig(config)
+
+			if len(test.expected) == 0 {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				assert.NotNil(t, p.config)
+				assert.Equal(t, test.expectedBaseURL, p.config.BaseURL)
+			} 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)
+}